From 5272abf40a15b1dd0310b8c7acc7dadba98f748c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 02:56:49 +0300 Subject: [PATCH 001/316] chore: reset implementation for v4 architecture --- CODE/Logicytics.py | 522 ------------ CODE/SysInternal_Suite/SysInternal_Suite.zip | Bin 923987 -> 0 bytes CODE/_debug.py | 278 ------- CODE/_dev.py | 223 ----- CODE/bluetooth_details.py | 144 ---- CODE/bluetooth_logger.py | 179 ---- CODE/browser_miner.ps1 | 89 -- CODE/cmd_commands.py | 31 - CODE/config.ini | 114 --- CODE/dir_list.py | 70 -- CODE/dump_memory.py | 173 ---- CODE/encrypted_drive_audit.py | 106 --- CODE/event_log.py | 83 -- CODE/log_miner.py | 54 -- CODE/logicytics/Checks.py | 92 --- CODE/logicytics/Config.py | 48 -- CODE/logicytics/Execute.py | 96 --- CODE/logicytics/FileManagement.py | 230 ------ CODE/logicytics/Flag.py | 820 ------------------- CODE/logicytics/Get.py | 55 -- CODE/logicytics/Logger.py | 454 ---------- CODE/logicytics/__init__.py | 126 --- CODE/media_backup.py | 138 ---- CODE/netadapter.ps1 | 3 - CODE/network_psutil.py | 195 ----- CODE/packet_sniffer.py | 149 ---- CODE/property_scraper.ps1 | 34 - CODE/registry.py | 29 - CODE/sensitive_data_miner.py | 179 ---- CODE/ssh_miner.py | 47 -- CODE/sys_internal.py | 93 --- CODE/tasklist.py | 33 - CODE/tree.ps1 | 9 - CODE/usb_history.py | 89 -- CODE/vulnscan.py | 196 ----- CODE/vulnscan/Model_SenseMacro.4n1.pth | Bin 1389747 -> 0 bytes CODE/wifi_stealer.py | 109 --- CODE/window_feature_miner.ps1 | 3 - CODE/wmic.py | 40 - MODS/_MOD_SKELETON.py | 54 -- core/.gitkeep | 1 + logicytics/.gitkeep | 1 + plugins/.gitkeep | 1 + 43 files changed, 3 insertions(+), 5387 deletions(-) delete mode 100644 CODE/Logicytics.py delete mode 100644 CODE/SysInternal_Suite/SysInternal_Suite.zip delete mode 100644 CODE/_debug.py delete mode 100644 CODE/_dev.py delete mode 100644 CODE/bluetooth_details.py delete mode 100644 CODE/bluetooth_logger.py delete mode 100644 CODE/browser_miner.ps1 delete mode 100644 CODE/cmd_commands.py delete mode 100644 CODE/config.ini delete mode 100644 CODE/dir_list.py delete mode 100644 CODE/dump_memory.py delete mode 100644 CODE/encrypted_drive_audit.py delete mode 100644 CODE/event_log.py delete mode 100644 CODE/log_miner.py delete mode 100644 CODE/logicytics/Checks.py delete mode 100644 CODE/logicytics/Config.py delete mode 100644 CODE/logicytics/Execute.py delete mode 100644 CODE/logicytics/FileManagement.py delete mode 100644 CODE/logicytics/Flag.py delete mode 100644 CODE/logicytics/Get.py delete mode 100644 CODE/logicytics/Logger.py delete mode 100644 CODE/logicytics/__init__.py delete mode 100644 CODE/media_backup.py delete mode 100644 CODE/netadapter.ps1 delete mode 100644 CODE/network_psutil.py delete mode 100644 CODE/packet_sniffer.py delete mode 100644 CODE/property_scraper.ps1 delete mode 100644 CODE/registry.py delete mode 100644 CODE/sensitive_data_miner.py delete mode 100644 CODE/ssh_miner.py delete mode 100644 CODE/sys_internal.py delete mode 100644 CODE/tasklist.py delete mode 100644 CODE/tree.ps1 delete mode 100644 CODE/usb_history.py delete mode 100644 CODE/vulnscan.py delete mode 100644 CODE/vulnscan/Model_SenseMacro.4n1.pth delete mode 100644 CODE/wifi_stealer.py delete mode 100644 CODE/window_feature_miner.ps1 delete mode 100644 CODE/wmic.py delete mode 100644 MODS/_MOD_SKELETON.py create mode 100644 core/.gitkeep create mode 100644 logicytics/.gitkeep create mode 100644 plugins/.gitkeep diff --git a/CODE/Logicytics.py b/CODE/Logicytics.py deleted file mode 100644 index 3149d645..00000000 --- a/CODE/Logicytics.py +++ /dev/null @@ -1,522 +0,0 @@ -from __future__ import annotations - -import gc -import os -import subprocess -import sys -from concurrent.futures import ThreadPoolExecutor, as_completed -from datetime import datetime - -from prettytable import PrettyTable - -from logicytics import ( - Log, - execute, - check, - get, - file_management, - flag, - DEBUG, - DELETE_LOGS, - config, -) - -# Initialization -log = Log({"log_level": DEBUG, "delete_log": DELETE_LOGS}) -ACTION, SUB_ACTION = None, None -MAX_WORKERS = config.getint( - "Settings", "max_workers", fallback=min(32, (os.cpu_count() or 1) + 4) -) -log.debug(f"MAX_WORKERS: {MAX_WORKERS}") - - -class ExecuteScript: - def __init__(self): - self.execution_list = self.__generate_execution_list() - - @staticmethod - def __safe_remove(file_name: str, file_list: list[str] | set[str]) -> list[str]: - file_set = set(file_list) - if file_name in file_set: - file_set.remove(file_name) - else: - log.critical( - f"The file {file_name} should exist in this directory - But was not found!" - ) - return list(file_set) - - @staticmethod - def __safe_append(file_name: str, file_list: list[str] | set[str]) -> list[str]: - file_set = set(file_list) - if os.path.exists(file_name): - file_set.add(file_name) - else: - log.critical(f"Missing required file: {file_name}") - return list(file_set) - - def __generate_execution_list(self) -> list[str]: - """ - Generate an execution list of scripts based on the specified action. - - This function dynamically creates a list of scripts to be executed by filtering and selecting - scripts based on the global ACTION variable. It supports different execution modes: - - 'minimal': A predefined set of lightweight scripts - - 'nopy': PowerShell and script-based scripts without Python - - 'modded': Includes scripts from the MODS directory - - 'depth': Comprehensive script execution with data mining and logging scripts - - 'vulnscan_ai': Vulnerability scanning script only - - Returns: - list[str]: A list of script file paths to be executed, filtered and modified based on the current action. - - Raises: - ValueError: Implicitly if a script file cannot be removed from the initial list. - - Notes: - - Removes sensitive or unnecessary scripts from the initial file list - - Logs the final execution list for debugging purposes - - Warns users about potential long execution times for certain actions - """ - execution_list = get.list_of_files( - ".", - only_extensions=(".py", ".exe", ".ps1", ".bat"), - exclude_files=["Logicytics.py"], - exclude_dirs=["logicytics", "SysInternal_Suite"], - ) - files_to_remove = { - "sensitive_data_miner.py", - "dir_list.py", - "tree.ps1", - "vulnscan.py", - "event_log.py", - } - execution_list = [ - file for file in execution_list if file not in files_to_remove - ] - - if ACTION == "minimal": - execution_list = [ - "cmd_commands.py", - "registry.py", - "tasklist.py", - "wmic.py", - "netadapter.ps1", - "property_scraper.ps1", - "window_feature_miner.ps1", - "event_log.py", - ] - - elif ACTION == "nopy": - execution_list = [ - "browser_miner.ps1", - "netadapter.ps1", - "property_scraper.ps1", - "window_feature_miner.ps1", - "tree.ps1", - ] - - elif ACTION == "modded": - # Add all files in MODS to execution list - execution_list = get.list_of_files( - "../MODS", - only_extensions=(".py", ".exe", ".ps1", ".bat"), - append_file_list=execution_list, - exclude_files=["Logicytics.py"], - exclude_dirs=["logicytics", "SysInternal_Suite"], - ) - - elif ACTION == "depth": - log.warning( - "This flag will use clunky and huge scripts, and so may take a long time, but reap great rewards." - ) - files_to_append = { - "sensitive_data_miner.py", - "dir_list.py", - "tree.ps1", - "event_log.py", - } - for file in files_to_append: - execution_list = self.__safe_append(file, execution_list) - log.warning("This flag will use threading!") - - elif ACTION == "vulnscan_ai": - # Only vulnscan detector - if os.path.exists("vulnscan.py"): - execution_list = ["vulnscan.py"] - else: - log.critical("Vulnscan is missing...") - exit(1) - - if len(execution_list) == 0: - log.critical( - "Nothing is in the execution list.. This is due to faulty code or corrupted Logicytics files!" - ) - exit(1) - - log.debug(f"Execution list length: {len(execution_list)}") - log.debug(f"The following will be executed: {execution_list}") - return execution_list - - @staticmethod - def __script_handler(script: str) -> tuple[str, Exception | None]: - """ - Executes a single script and logs the result, capturing any exceptions that occur during execution. - - Parameters: - script (str): The path to the script to be executed - """ - log.debug(f"Executing {script}") - try: - log.execution(execute.script(script)) - log.info(f"{script} executed successfully") - return script, None - except Exception as err: - log.error(f"Error executing {script}: {err}") - return script, err - - def handler(self): - """Executes the scripts in the execution list based on the action.""" - log.info("Starting Logicytics...") - - if ACTION == "threaded" or ACTION == "depth": - self.__threaded() - elif ACTION == "performance_check": - self.__performance() - else: - self.__default() - - def __threaded(self): - """Executes scripts in parallel using threading.""" - log.debug("Using threading") - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor: - futures = { - executor.submit(self.__script_handler, script): script - for script in self.execution_list - } - - for future in as_completed(futures): - script = futures[future] - try: - result, error = future.result() - if error: - log.error(f"Failed to execute {script}: {error}") - else: - log.debug(f"Completed {script}") - except Exception as e: - log.error(f"Thread crashed while executing {script}: {e}") - - def __default(self): - """Executes scripts sequentially.""" - try: - for script in self.execution_list: - result, error = self.__script_handler(script) - if error: - log.error(f"Failed to execute {script}") - else: - log.debug(f"Completed {script}") - except UnicodeDecodeError as e: - log.error(f"Error in script execution (Unicode): {e}") - except Exception as e: - log.error(f"Error in script execution: {e}") - - def __performance(self): - """Checks performance of each script.""" - if DEBUG.lower() != "debug": - log.warning("Advised to turn on DEBUG logging!!") - - execution_times = [] - - for file in range(len(self.execution_list)): - gc.collect() - start_time = datetime.now() - log.execution(execute.script(self.execution_list[file])) - end_time = datetime.now() - elapsed_time = end_time - start_time - execution_times.append((self.execution_list[file], elapsed_time)) - log.info(f"{self.execution_list[file]} executed in {elapsed_time}") - - table = PrettyTable() - table.field_names = ["Script", "Execution Time"] - for script, elapsed_time in execution_times: - table.add_row([script, elapsed_time]) - - try: - with open( - f"../ACCESS/LOGS/PERFORMANCE/Performance_Summary_" - f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.txt", - "w", - ) as f: - f.write(table.get_string()) - f.write("\nNote: This test only measures execution time.\n") - log.info( - "Performance check complete! Performance log found in ACCESS/LOGS/PERFORMANCE" - ) - except Exception as e: - log.error(f"Error writing performance log: {e}") - - -class SpecialAction: - @staticmethod - def update() -> tuple[str, str]: - """ - Updates the repository by pulling the latest changes from the remote repository. - - This function navigates to the parent directory, pulls the latest changes using Git, - and then returns to the current working directory. - - Returns: - str: The output from the git pull command. - """ - # Check if git command is available - try: - if ( - subprocess.run(["git", "--version"], capture_output=True).returncode - != 0 - ): - return "Git is not installed or not available in the PATH.", "error" - except FileNotFoundError: - return "Git is not installed or not available in the PATH.", "error" - - # Check if the project is a git repository - try: - if not os.path.exists(os.path.join(os.getcwd(), "../.git")): - return ( - "This project is not a git repository. The update flag uses git.", - "error", - ) - except Exception as e: - return f"Error checking for git repository: {e}", "error" - - current_dir = os.getcwd() - parent_dir = os.path.dirname(current_dir) - os.chdir(parent_dir) - output = subprocess.run(["git", "pull"], capture_output=True).stdout.decode() - os.chdir(current_dir) - return output, "info" - - @staticmethod - def execute_new_window(file_path: str): - """ - Execute a Python script in a new command prompt window. - - This function launches the specified Python script in a separate command prompt window, waits for its completion, and then exits the current process. - - Parameters: - file_path (str): The relative path to the Python script to be executed, - which will be resolved relative to the current script's directory. - - Side Effects: - - Opens a new command prompt window - - Runs the specified Python script - - Terminates the current process after script execution - - Raises: - FileNotFoundError: If the specified script path does not exist - subprocess.SubprocessError: If there are issues launching the subprocess - """ - sr_current_dir = os.path.dirname(os.path.abspath(__file__)) - sr_script_path = os.path.join(sr_current_dir, file_path) - sr_process = subprocess.Popen( - ["cmd.exe", "/c", "start", sys.executable, sr_script_path] - ) - sr_process.wait() - exit(0) - - -def get_flags(): - """ - Retrieves action and sub-action flags from the Flag module and sets global variables. - - This function extracts the current action and sub-action from the Flag module, setting global - ACTION and SUB_ACTION variables. It logs the retrieved values for debugging and tracing purposes. - - No parameters. - - Side effects: - - Sets global variables ACTION and SUB_ACTION - - Logs debug information about current action and sub-action - """ - global ACTION, SUB_ACTION - # Get flags_list - ACTION, SUB_ACTION = flag.data() - log.debug(f"Action: {ACTION}") - log.debug(f"Sub-Action: {SUB_ACTION}") - - -def handle_special_actions(): - """ - Handles special actions based on the current action flag. - - This function performs specific actions depending on the global `ACTION` variable: - - For "debug": Opens the debug menu by executing '_debug.py' - - For "dev": Opens the developer menu by executing '_dev.py' - - For "update": Updates the repository using Health.update() method - - For "restore": Displays a warning and opens the backup location - - For "backup": Creates backups of the CODE and MODS directories - - Side Effects: - - Logs informational, debug, warning, or error messages - - May execute external Python scripts - - May open file locations - - May terminate the program after completing special actions - - Raises: - SystemExit: Exits the program after completing certain special actions - """ - # Special actions -> Quit - if ACTION == "debug": - log.info("Opening debug menu...") - SpecialAction.execute_new_window("_debug.py") - - messages = check.sys_internal_zip() - if messages: - # If there are messages, log them with debug - log.debug(messages) - - if ACTION == "dev": - log.info("Opening developer menu...") - SpecialAction.execute_new_window("_dev.py") - - if ACTION == "update": - log.info("Updating...") - message, log_type = SpecialAction.update() - log.string(message, log_type) - if log_type == "info": - log.info("Update complete!") - else: - log.error("Update failed!") - input("Press Enter to exit...") - exit(0) - - if ACTION == "usage": - flag.Match.generate_summary_and_graph() - input("Press Enter to exit...") - exit(1) - - -def check_privileges(): - """ - Checks if the script is running with administrative privileges and handles UAC (User Account Control) settings. - - This function verifies if the script has admin privileges. If not, it either logs a warning (in debug mode) or - prompts the user to run the script with admin privileges and exits. It also checks if UAC is enabled and logs - warnings accordingly. - - Raises: - SystemExit: If the script is not running with admin privileges and not in debug mode. - - Notes: - - Requires the `Check` module with `admin()` and `uac()` methods - - Depends on global `DEBUG` configuration variable - - Logs warnings or critical messages based on privilege and UAC status - """ - if not check.admin(): - if DEBUG == "DEBUG": - log.warning( - "Running in debug mode, continuing without admin privileges - This may cause issues" - ) - else: - log.critical( - "Please run this script with admin privileges - To ignore this message, run with DEBUG in config" - ) - input("Press Enter to exit...") - exit(1) - - if check.uac(): - log.warning( - "UAC is enabled, this may cause issues - Please disable UAC if possible" - ) - - -class ZIP: - @classmethod - def files(cls): - """Zips generated files based on the action.""" - if ACTION == "modded": - cls.__and_log("..\\MODS", "MODS") - cls.__and_log(".", "CODE") - - @staticmethod - def __and_log(directory: str, name: str): - log.debug( - f"Zipping directory '{directory}' with name '{name}' under action '{ACTION}'" - ) - # noinspection PyUnreachableCode - zip_values = file_management.Zip.and_hash( - directory, - name, - ACTION - if ACTION is not None - else f"ERROR_NO_ACTION_SPECIFIED_{datetime.now().isoformat()}", - ) - if isinstance(zip_values, str): - log.error(zip_values) - else: - zip_loc, hash_loc = zip_values - log.info(zip_loc) - log.debug(hash_loc) - - -def handle_sub_action(): - """ - Handles sub-actions based on the provided sub_action flag. - - This function checks the value of the `sub_action` variable and performs - corresponding sub-actions such as shutting down or rebooting the system. - """ - log.info("Completed successfully!") - log.newline() - # Handle sub actions for all actions except performance check - if ACTION != "performance_check": - if SUB_ACTION == "shutdown": - subprocess.call("shutdown /s /t 3", shell=False) - elif SUB_ACTION == "reboot": - subprocess.call("shutdown /r /t 3", shell=False) - - -@log.function -def Logicytics(): - """ - Orchestrates the complete Logicytics workflow, managing script execution, system actions, and user interactions. - - This function serves as the primary entry point for the Logicytics utility, coordinating a series of system-level operations: - - Retrieves command-line configuration flags - - Processes special actions - - Verifies system privileges - - Executes targeted scripts - - Compresses generated output files - - Handles final system sub-actions - - Provides a graceful exit mechanism - - Performs actions sequentially without returning a value, designed to be the main execution flow of the Logicytics utility. - """ - # Get flags_list and configs - get_flags() - # Check for special actions - handle_special_actions() - # Check for privileges and errors - check_privileges() - # Execute scripts - ExecuteScript().handler() - # Zip generated files - ZIP.files() - # Finish with sub actions - handle_sub_action() - # Finish - input("Press Enter to exit...") - - -if __name__ == "__main__": - try: - Logicytics() - except KeyboardInterrupt: - log.warning( - "Force shutdown detected! Some temporary files might be left behind." - ) - log.warning("Next time, let the program finish naturally for complete cleanup.") - # Emergency cleanup - zip generated files - ZIP.files() - exit(0) -else: - log.error("This script cannot be imported!") - exit(1) diff --git a/CODE/SysInternal_Suite/SysInternal_Suite.zip b/CODE/SysInternal_Suite/SysInternal_Suite.zip deleted file mode 100644 index 4d31094617047da64dd478907c41376952d40a48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 923987 zcmV(zK<2+tO9KQH000080N9BtSx`{d*mmpz0DzSP015yA0C00=X>4UKWq4)my$g60 zMbNViH{?8^;@lZa~*SLT5Cca(3Bo z^Pp^ZsrUe#~I;IQ8;cpr~b?3e)wOij^jp8d1Ey9+Nk%YZr5G+ z-qahaZ(ozXy5^pr*4%Pe`mMLzefK>=`cGD+*NAtg-+p)c!s|=Y@4DxnSTD=_ zpW|l0(+=tC+4HHf53=W~aT1G9W>5Q}$HwAw+3mMhV}0jpo72v5*XiQ8Kfg5nwn$hv zr`ILvMqkLq>p8AQ$HIQ)fFBF|rR!C!;XAy>v)JJ$x5dDayx>0!fbZOTmJriMpV0^V zmvyaydl+Bp4BVo)n25U#T;0X++-%@(PKD?z4crafx&P58^&|Td;CvcDzjHC_AB6-sEg2=MR5#izhB zM160=_kC*W+`A$59hmwb7`j7EaRlH0>#9ikrKGpGgp`|IW#oihJ6L~eNKrQO4*|b$ zZAB>w@Q_ZK)?DbFd!AfraVesyj83{10(Y9NzAD*xb!Ej$@{jUn6Gx6qdlTx1F#Fns zckmHR+@1*eZ6Q8CbJ+{bGJQ%U^%7UR9D$E zb8ji&tbcWS?R~dBi@{!dsLdW?3EZ4AetmjA=kf8L3qWWp;~#)8DSQv_;o+0mL)Lz) zB8x|5v0JX~k?p+z=UodF#s@PXg?BzzuP~%t>@AvRYvUiB2%%1T!(0X_l-rPm$yQI3 zKJS(b2ZQ50ijZ7J=c@4}6x3V$iKEvrgq76xc#Wd~C>JeuFrt@hyBV~D3|f~8EGadS zy+B3$7`-=7-#^>Waz=;JOwFgKDiE{1~n+SFJTSMSVoR zRB}5rE5%`n1_2fH+o}kqsQoqcL;cle@4*LhS1r&sKPI8ANUg6(tv&js)}Bz^0MsJe z2O%RW26Qge?bgvpe~LZx={AO0Ivcbrep^TuL()((-;jpgU1lNIr^uy8Wk;`V|C%Y@ ze4SimA#KQkdXix6sFLS8tK{pP`oLi*cRLC)V3M$o-}D{QRlbgdWPA4+vhD)1pQYXZ z&&+09dnDB4H6}*nAK3@Yix9NS>r#ll8>_V+HTYx)m1=uUPVZb3&_~U+!vX;q|ewf>cQ&0a?z{q!W1keycm zyw~TXJ9E`k)vK{gQ&uET_0#wrzAT15faDmrtt&A27O-Yb^DdeONVFEyP!=K#xvWE& z>CKxab$82)22l_IlO{W9XBGn%1FSy$rFTG4RJ@JjqVi|^EU41c!Q1^HbhdA~vw({T zpNRYsO+Y#~u3|-b9p+--PA5G81u3hIN*3L*1!Gbx%3E0a`d(DTH4+9AEJKp*@F;vk z9pHzQn!Wi7aOPU+q)xoqRc%3G;q~+af}ON{F{8{j4~5SZlp5^K+V51x-^qlOa6Vya zrEvVZWcU%2b6yX@_u9C3kkI{~C%N2QnKFJgN{RLg;;$r*LF;?eh?s&-azmVeaS-aW zzE??um#GoM_;#4dewVjK&riwas)$}{)+EXMfQbDkGmin0SMgICn7+bq8{=K9uaa*V zJyhj6AciW+M@o2xj~V({d=R^tI83s)=Q~9K)gncHlET|;!~@^(OQbHU6E2nL#p2b~ zeGpizjDHw9#nvTW>W)*!KMn6I)i!0Rpf_6p47&xh72(g^*~cN9FwW}J(wU;^VheQA zDF)W10S5%SG59eE4qSs@y2#h%$dbw2%>euX7ejHGr$d3WPqI=Eh#ku7h^ba%4v1VYZ!1+QLD9|jna zm}DRYmEm`i6#lPMP~EZDwVoj*wn`rxy07XqdUscI5*H zD=VPO%UdcdSmi6rTcYyEJ{TE5t97ii717*g4`T?xqKr2&-V(2NfpBWG_n~3~gHPD$ zMqHS&I&n}LZ%k(|B&3W_!3_NsqS@-pv4{CbeaJ#qANj}G=-+%A{hOTCFF%;iRd>Np zY{$_HMsb`P1v%>IH464n8UQw54b@@jOcn~Q7B5mWJJs+p(eTkK*^il}a`td<(4ov& zW`}?7ZWOO+G5hT}n>*3dJQpAU4yH9!FoygwW;cYp*kubd6h%a66qJtNJxy`d)QCWh7 z5VSt{@vKHUK3>2g?NGoT+1^Ro9v>L0zWJo0-0pii4z&*&qlB;s*&ZVH_QwZKsVQU_ zdF_XIi@nFmkFg(8#y3EDUQaa?4dlcK4?qBl!^+xNcnagpJK=31a4`sMgrIyUS%A!j zbmtxo%%wGI{GC{yeUH`(~a-JrRBe2DTo-5$;LEt=)n;@Bc!I>(808!Hwg{Y5cr}ezPUQ zyVD?p#?o8pk!UbzHkLx`5#a0H!HdbnH+4+CQf#5x6ymUug|8T8<12+^A`5%qYk0^B zB`8=X=~xjvFarQ$KWz0?dX7{8kAg&~o&gmBHs-^_s!&Im@eI&Z7LJJ_Pe0#Ki(qa? zER4@!obf)W=UqATFlfWAQGs-M6w;|tfpmT}EVL-8}wNd2tL#Nm%a!O?xS`WF%`6&3H zK&jTu@t)tp0P3Qtrx1xo*>1);R1<`ikp*UyCjGL9p2D#Pf1TBh4Vq+w5eWiJ@G8IF z18>R)nA3;oVKGikuz5Wg>vUE3Ac9}VQdB7nV&Z~`@Ee3U`s|-H%rkQ37lyRd2Vur@ zhl(T^g8S*Leg^CC`0_I1Yc0S`SHo!1)WyHk;;i;z;4Mr$FxQ>BPl&5KwL(l_{YM7s zPTege)}2}^#7RRs@f{TzTgButqhkIjb!mM1Qq*3Ihw=uEq%x3eRbCvOPk?zC55uMQU z-gRT?Ro{ZX>1#{qG_}{sMTu=iqqB9d!IY}VB*#mw#_C>3=c0!oRkcNeEOQIDo74pc zsa5ZyZ(>B27O@G5xYZziKG;@hO5hA!TcP<9-Dr4N`gMxJmJ*yW=SY!SC%9-G*4$Ag zn+vOyBA6N#dRND7N1Yk+SMP)(p^y&lDJ*0T#|qJLY9YGeLTukx$RV{5WkezRGYjeO zJ9i7zsO(S5?lUnPAh+GG~1KUo;H6=AE~c7Opzf@ z)vSrbqN?(!v z$uOgspN^_+&v(PJoW@PUI$iVxLxPcn)rc}ziIaYv3MH7K+%kG2N~an4NC_SQCDoc>DE$ITK~*;fjczW2M;eRAwhGw zM3OE#zzrjjVfAzFG$j6wiy-mH@fajx;*u!-QUG5IZ_u!_hC}=X#{!13B4L#r!&v$a zh_Sn#V;IBLd#2y7nQ{*NcwWXI%jqJL)ck^IG^L#d~PYz zj8dZ=7*ZdY!dXT+EWlEyUQa(ftC z(F3#tGuDv0Tq>`kAoM`sp1ugca^qqN{+J^WGd88SFjX-{n<#5@eJ?A(sikqz>&CMc zN+u*AiACdZ$yi_rWaC;MOg3vS+&X zwBU1@(`{8t2SItWwlwx$1wD{K*djRDdU&JtGYMXa$0|7oxc8J?Lejn7PnNXBY9QM zXmM=i9AAYhn?9JVwpr`3>gyVD>nWE_EX{46PK}e)erRKy!q|>=vzkXmx0j^_eWT3T z=AltmJQRJy{xq<+UM-9o#&R6LHTf-2=RCeQRK0!|j4F*R2mq`^>YO|IEYx|{^`^Q7 zm=CuYg-e{QDI?aLE@7hHqK3J3NP-WI;Z!D|dncV%%USfV4hmFe0--B)=!$9IZ_d!c z&Lywi3TAJjQT6RCSx=YmH$_uF_1HP&yVPqcA&1otzScy>W$xO4LM|V)9pfK-0k=km z)U7*gJvp%*KlXo}*@l}k@8U-=!EnMh#ODvel+JTN$TxIon<;2t%=f1 zMps_r)pCJxc*DHFnAyBPEZf7j&{`X?ly@%YDp^L~z;!YEpyBV(z)TT~TyJ!hl+oX1 zvQC3i04s9da6w+<705l_x%y?aR;@~2GaOCj&p`y-dM(Gr;16}?GIC-E?otsyDYIlY z%cX<1R(=yMW_gWsNLdPQbcLn83392)c5JPY91EtB=KVCY-L^lnzm;HUj@{n63~C}> zYHBxR7PDQiZzr&>k*AP0t?d@Btc2ci3CFp#f-BX6p;(+{nMj*FC7P5j z`vgZM!-jaYjl<~*;4MmnJ{{2GB8M~8}+HSSF-%@H)QT`2J z+`Cj?MQ|pqZn+#I*Qe|sk{w~&v2~Y_kJVksDe^jtq2INvg#KwJ7qyGHAlfb?r|v!S z4?U0M`FCk|f@-(1HsQ^)ZbrU2ycyZW=XZGvSZ~$$D2iGW60CjDDOaY@`M3mF4w^38 zF*II)=n9)^W@|k_63$^iFp1d@+&ljCJ}`2WF5)=a_nD%^;M1&JiQL{}=GdZ~SdbHU z^yUIi94{>h!_&0A2Y&g2ID7=hNejAJ;2t#)A5LYRxmo~ON3ZH(gBxkB#0j3d-@IU4 z%zpC?QBfIkl?`t>Tl?GWuQ91EZkNU9Wbp-Ad|4I;bLJr=d_x_Z4)A3@S)|puGDuZN zmn`=3W9(hD^{Ap~K)r#`5C`Q8#7n45roFp~f=0WDrQM%-3e`WczVr?C^Ss zy;|58kYO&8p}i9-G*~;%vQgiQbTx;Rc4-DCq*?35l##v2KlC+3gRpyX9P3A`Pg@z6 zY7d#b4xmDN7hmA;5qoEwy%&r3n)4*T-fQ<^3ckSZ%WU6&qS)zmJV%$E#F_LVt!m#7 zR(+pZ^`&an1bbxrGwry)7FPI%2eC58YsGE@D5-BIG9}gV7O0+{Ug)RnxNaB%#p3@y z70tsMsuw}`(qo^%@F2cMN2wFvz^`!ALVV3d7qN{r6A1?-wzuT9KMiEG#pzbO_Sc+l z0}NjCjzaw|nidOK39~NQf@|fY5XNMLk@vJB7LqZ&r$Z=~7_o3-Ml8t18qCh>)d<+~ zPZ)&qV*$sz)#WE8r*;ef;IA2j@Kc}xuNhjM^jr8Y zl6D%U))ZUqmYM(&w+Gj@*_rh!C~BLwU37V<3~Ymg?>J@izM~Rd?aX7Wk+R5CuL5@ z_7sq5(t;WAYbpUe;U`;RvL0x3D6X<9*&>(s<&>NFpQCjrNZdAi7#Ywz_es`I#;f^l z7P3nU%;p;ivUotxH?Z~GlQ)SORXI+*ctKsQ!ilAl&$xqi1HJ5oqP(Jm@&jqOcD?|4 zM-k^r3y_*QF>@Jx|9L3dd^Owm6=qd4<_i5hpDU)**zA+7WE}-Klq+eyx;w9_^fF_K zfKQJ$2f2Et7nAlXx@jPF9%sZlFTXbYR7R~yMUa%l+0X|~@MGgy=JEr8_A zWBZ3}$Jfo3PMPF+W(at9kWuOad>Hy&%gShY>hP$2b!!|JuM2#n>@Cvm7-ap>2E&h9IUBq~@&bg6gv(@mFTEdX zj||^e1C%v$pTS(-v~s3Gonp)~fh-!7TBc?CBhvTRD6IS?PWq>0bqHtei2$Fj8^&4w z5aR4p6=(B?hH=&lINLk|Uo(dBHJjlp1M!t|3&U5+EfIXRr^nzc@L$|8qT+S`Dx$D- zC;jaaL{vruQN8kl84*N!h{}2<<(AVCHFhMT*3~d1$z}~nn4vWek)b2yeZXY} ziap%qP~f*fF7LAe|2^C^7$q_xwV^`W82POyO4KMi*3Ieb(* z3A`P}H`BxLb^za2<6Aqvh4Aeie7o5TZ?EB7JHGvqg#pjgr$3C?l>NttDAi`o@trZB z-A>Q2$2x8P_+iuzD-=eIrJcS%E%m02TY`6p6tq^s$xIXwe!=M)nK6+45v;mWE z_y9>p9W+^a+8~*X@7lN-RSW37zvR@t2)^MdTz2@hPf^qj@B$qd89Ls;X6zUoE^E9x zuAn?IErX&5&sOw9UomB)!6!_oq%W!plhBA!n>xQ{s0vejj1Kjqtk%Vj9?29s6C+eg*j5jy53y?ET_p&gwfC z1F00i=y>A_rF%PW`vv|1Jg}YFs~y2NwB!ox$2YX>3bf%HnsWr+#y7N}3%rVNXr&T( z5#P|VCh!!#t&gqhVXUf4uT>>Pt6Ckc>VargcSftKj#M=em;2j%c5X$uh%zf8(_IBz z?z8wSg>|C2+6-@QBYhr7F0c^lX?Z3LnMgV_>R<=+NKnwJm>LT;746?V{co!D+H1@&!6)kVzXHb89^E3E@!ca_0J$f=C?VM}Uvddn2jOum_BHx0Z$trVtr zOQwk?L$me3z<~7tX@^l@V8GBWz_=G06>k`j0y?p;vZ4c*hOj!4JfF+OrBZ2!058dF z7m`=H;RTf6tN3m8De&t3Wh(rV-! zo?$yyLvd?%C0l;7o8)XX)m)MRQ^b689G>F|e#)vYE_R`DoYy|+q-Z|Hx+Mt`x3Qy1 z!2%EvcOeT+9bh(gb!~C@jVhal5Jenx(N)I(zRpGh z(uq7mpv*%WqPL=5FAmHdEp+>L(az-VI!kA^#o;%{HZ9bin+tfPOWEG8>0NATn|%)+ ziIW|jB+p1z0G3Fx+3+#B*JAjt%Z% zF+WKBemZ5KQ|i{q_M_fx@g+4M-gwa9w{kN#4ct0cBD=WTB@FeAPe5wjOKd19!1T&52mzQ z-Va#9=~}y%4n^8kQoSMCE@G#FA&#w0lD3C*wl*=LOkJ#3K?D2%RAV)$^dnpFD4Q~x ztVeQ|rwFrimZu3bNYVWI`Pq0LP)s9^aL#qvCUKnYf$X_rG7&lo+atncX;rUk zXBS6yV~2}0(hi!9RMiSxg#>N&m5~#JQ1>y`peoaA~Z{VrS>@Zyl$n!2KXs2je; zuPR7eWeaE|k(BpEQf44F4_#**R0&QeM(;u>G|wp~XWfEQhiW7!Bt`%O za9NymBC)13X6d1#qa~uWLce)@!dQu#>a#!k2xvXQNq_$!q+w75lbPoGEqtRb34T1= zsD6B2-XhhS6qu{;Rg^#}s2*y==ub^7w=Ee#7IiHFNsYsPfZl0~?|CwxQ~P5VASv1( zBlYoO?{yjMpcrrPpVyJ(UlG6qpV82SNGOg2k@UvQ_VauLozvRM&goo8QEcrsiNwF+ z5yHvFjF#Cb*gae@ptjmY7R@)b^V{-Lyv3^U=Qs1G_6~t)_8f~3|#p)xwV9Xv3gG>m9 z{gy2{*6nD3AttoO^+H-5{WXA2J?C*;gHJT#jD$YP>F^`b&C!P;d_^#@MaR0KZo!v# z;}pP%-?$9q=~s;ce?l`I%sr{8X#}~|!Iutn>Q_0DF z>^IDQ2iY%o5B?hO!I>o1rvtFdTikJUPXgfBZK{3&_=Emx6v%~DgWPta(F(pK2aHyxTv=H*#~o15rb#B`^f=b%@+iR^JZniH9P1>t%S0ud6k4;#j24 zYZb<6G;=cYrG{T=0pbK_d13;+^E=?KBo&t*!TXAVt`&@|?51*!d1*W9oGiF&5%h># zM`uF0^oCKm(Ruh6@VY$#cNFMS7@*xcIg4H~#I*hjP#4x5`IJ722KWn0b77i_`oxX# znDLeYgbBK?m|7s7E+563mQ-?w?`q7I#W@JNECZ3Cupp0i($ZoPs)` zt~o~<%fG-*r$z{Fv>$z@2e4!9$g}zRhRbxUj>U2^23Cis;(-JBp>0EIbA&Nk2X4oG z=fKx!19S*ey@09KvQ%wxm>JcoU|iE-s7(=%(({o}Xa&w{LRKZ`YT-N=6;-$d3%R1q ztuGsvbaXyT2dT!n==EAElX3+e`w-$S7{)Wdv<_+`S#%9N%3Ls&w};eCn-EQmXV|-= z;fNSVcRYye>wt!BSkQ`yxk+nq9&z*%9S9VAcd?T$Jch6%iPGJ1Ec=|g+Al!&q_;oF zagkj1?$hZOdSks2Et;Qr5UH`7E`JdFW*2}E5Fk%8$Hgubt4lM*EzaANdCVMOVTL*L zwhS}qlJX2fBVz+7w@{_Rls;{g_@ONFs?gbTAxlg*fgs?d-V7;u5hT{R6P58H_4sPT z%cvWy3IQQOdDaeZ?a>nccjWlW&2oag&M04RvUbR2W@0}|YP&(ZtRE;^b+-um*}U8D zUbUE6M)b_PTUd1`s0&tKu9nwZepEIP`(TnioYW?-G0NAP|eDOL? zn6}{Ncs(biEm)0@@e7>s22L2g;AVUzK%jvW$B+ePfL+?Bli5o!UJW3Q9(TfW*)f=7 z?|UH2+7Vl?S|6y6P$env9%w;Y6h=$?^jaAPQXYB+5h1nf+u{&6sChq&Ap?G#Gw=qrH>m{}A_erfqzg8w zJzWisiv)wLHFQqvvUQ0UKsj%8GtD9mKs{Dp#~J6L7iO5qbs3hV zcK9`mqu|>(@JX|UD;fBNbYbkkZ5b&B_3abG!Ii2RZS-G>3u01}d}1o(Pl0y}%qNer zoMw0)4CcYi@+OG6A;W@U`vd!r?EmNfuA~mJZ{RoyLFuWGVqr$gz~_ejHKT2iZk?$n z!3Js9>Xup$kk$&UbNF0mEwcoVsA#sk9OtnDsr*Onlrw?ALM;SR#q;F7-cm^X% zRef&;TBA>lP_hu`*25I=S<7l1`ONq`-h{vB8}WA@hrbI30f|))kDsv`AIWL#aekNu z#`Uqs^bmW@?uEw_zlcj;jr*_MvnlLpd^UA80&RLW1HU~-f!W&PZe|pur z0FS|6P8_|zfa4$a4P&At78S%{dT*xNz?Mkbj2U~_Ap-;!Ep=w|sy&D2VKh1NHsbH2 z4fy*c&R^u$AHd(Y*Q1Hk!;e149^S{;<4J)%JgeE``8(O;zpCN!#4kKg;H1TFls%8I zSrEKDtsW^HM6)^_M{)<jV)*Y&* zV-jaFm9aiYofB?XHJrRuag21IFnVp0w1e$YBZlYrTB`4YL0he{C2G8JW&PE+uDPpQ zoGxv!7{n>0Fl9k{+^;4H=P#Iwj|mGd#0S5C%Y9rYm?}}-M9YOL=qovktTWP8?2Ud0 zquCy5`m7od(JE)FR-3A@M{bnw%&>UjFSENuJLZ15eUM3fs)*?OLK~gfj4c!+_j^;M zRei`~(yA~rPYO2!$Vm9nL66fJVMd=3U1u`T%O3hzGgPz4D48?oIr$9bYr4j9d*9zC^MwvJxS+8g0jG z0w7f}Z5O8P`Vnc}rfQ1n)eX2&#pYZa>a!Uwtd-USEv&9zpTbCCEW9vc7zHm#4q_=; zl;-IWmXczl=YTL1^{{2i>QuUfi|QD`%h~!?FD>9A8cDFHUuS`-Ol~SML2e7t6~|0v zmG{r0OM?VZO3k<^hq^PU%)*?JWIL6S?RoVZR8UjvH>jZU@WLoJ9#6)?OlCfa3wgB# zDfV*qnnIWK1IePoo(=&dHv=#*8Y;{&31*UOWDA|XTs+!Z-lCliFNHapj@)ZBG<*8j zCzHh{k}`pq6AgO}{tA{zH6FXV{+eY41>JzSYaG|ZGqL^}1XegNIF7ys!a`b~%55;w z7vWV|l*)Ljh5jde$?Gj-t&wgXtpfPsx2S_{!W=$J3%!rMq_hNn`7Ik`5D|47jhwKE z@PN;JZ?R50lbyM%#N}O~E1`||p$57FCTEkBD@$DTn{U-+w!OHi*e{z}bB#I|U9=8o z;46bk*v14|*P3VKDx>YG+hF9F+By}JNf&Xgg+@+ouGE_Xlv9FMOK&3nWqSz7ZFYZx zMYw>Nq=3;?4ES1|PWF=ZsnJD9q{#jRlMweN3Kd{d#_@eQu=V zk8lBo*i(rkt;9u^Tgu3KGx-2*=f3fDte<^HbBferGPIC#i*!;E=1VkXNzU@YTB9v> zLmY&N2}B%R49Ptm_x(NJHn488?frEVn2!&0rM%i)A)92UQC?&MqN7)Ts#1m2n<}lM zAaWW+_-i*F1as&HOXIljWt9_>eSU!TrJ)roP#8!XkO1z>#t@xoeQzI;eCvGSw=LiFxE#srx&`VV?0evVav?9+qtyFxnC%67(Pu&19*`x|e?WB{NS})}|ruk4zwsrYCW4bWw)9)JVThLvX zd({NU*DY!`$!`oM*g`^*caf3kK-7#19wUWDSr`0}?*3RMx#AM$A!3oq>M+x#Uox@G zT2>{D!7YZ|3}(jS%MIQcbh~x9S-v`z{j!bo)oLr)D{DxY8H}YP(${BjViMbKFw@_$ z(*%+KxNKdGgh+2=#$0L8+1MAirja|+m2RkQrAD%N77XZM(CTwKovZ^=hE1U#R_g** z#<`(Ae5QeRnTrS+7N(m&kYOf%vft2Z8(M3!h51d7pkdjGbbiwTBigc}z0{g<{2SBR zJdfY>a|jK5hF&t>n2vKF-I`IypFaE5L}G%d$VFG9`%W}(b` zp2YW7_5Ed5;urcg$s`_xm3OUCPVjCtLXct2nB)A0(Ne(VU2Cf1-{-v>O#`jC`7EsB z-}v0XORuq`Z}%+i^RDe93cOWVq(Bxt3)A7}?kw-xEa?P(AFSf9;t!jv_>EKGs~f+X z@e2)P?|BbDd-3z0bnnKjHQT&v&4E8_MTfm>!`{W*50-5jQMPGh*{0aCO+gnHJm_6J z_#}_?uN318bq7 zvB(eu8~Yv!S8UjbwhXi(D+v7JrPpJd)KBr4%2GAe^@g7LFaUCSvxN&uT0LvWk z*Wt$IW@vuRH0i5!>Fe~EYP5o+K>C2c8`FI>;Ky|yVLYG)5JckT1fUc(Pz-`j!%JW5 zq_1??6x;+prnNF?!1aPwshdZmxzAAA@0UK==>KPeQ00A&IrL=o9gozGH0weFuK?2C zm{5fvRAF1Huq{>CWTbv)6*jpFn_PwML;7}BVf(7Eecr9uKJUBuyF`2zV5@|#{bwBL zzf-;c$&JLP)==+SV371B;ylv^{U24NPWd=B{GWtmYckG@)@K%jKtfAsI^sE(0E0BpzQ82n>z1kHl3Kek(4r zd;kL2=;`1a<^zG=?$g0Pq}K;O<|4wE-$b|(()N$I8beODL^(kE3zJfJ`>OcWKF+X@ zACn-pS)?yQnfuft)?TMUu01wu$VVBnu?Uf0g#A&3h%Q1z7a^jHR;wtEkQ0ZL@Yi5; zELd}Ov`(b4G!5597O5>A=nBHt^bYu^AP@Y+`vBh^jo08G@H3tr-I!1W=vTkQZwCCh zX(O2A1ne24Ky)G^q-&t7{VONPMMkO31Y%2#7r)dBW$X290S+{)+PVBT9qFv{_=GeU z3Gv(Bud<0|pk%)uBSI`nq%bR~vXz?oM}3Q?`O90X2)u7=#>Rbvje{0;g0eVW0sdVO zf}eF4@|IEjj5yzK(QTsWhreA_d=Q==_|PkY+Y;oV{3=_skcM@SbL*EI+JkTLZ}|-E zP*={F;ID8dL;R6(IXYdbHG_Z-4*>x!H5&F=&Dr!o59l6+CbHDbzAG!r1Ml{Xq^5{{ znR@=O)vj=gwat!a{5krQKj}ExfJ#Jx$-Bm)>d!8xETX1Q8*pV8G~*c-ruRuZE#znB z{#;{~c%y9ZVVWqY(LHj(pri~5w-ZO$Tcr0Ii%43%*H{npW!%E<0I~Z%9W{pInANSv zw?1X#qM)J4p^#J3sUfkWp+mSdn67!C2~NbKE~sX3yup^hchgy8*zuPzJj9jVpZ{Gt&%T=K_BzPKjKRb#}|h66%xlT&w9u}%a%Trc_c z4F|-YVApv*sXdw(r@;$yYYI3a!AWP|j$5a=;xkJ1#?o-X-#D&CT5vON@QMbjucf3+ zwJ%WS-OMaV=~g^L*R?`Ar3e!}N5mCwMUA3eW3=5QqxDEI-{qd+*3qBsXERisqK9$1DjkO*r(ZZf!VL>DLyS3j%|DzgbteQW!O472jrFzq?L&tsTI2%6inQQ<9$eZ~; zXxGT4w1>3Aq}y)l_nU+?vM3d+*uk-Lp$)H~$QkV|GC7NbSN139gax>4z&AXu#iP48 zWnBu)09^DL)fa-aF~umlq^mih81uoDec{)*StQ2?bD{K{%2aV0aL$c@YZqi?Wz(rF ziPJ@IQVR)=ha^`*5^H~g(ao*oA8jd+%ey06M=il5T)f5QoUzss+n=~(L%D}%lv;oorK`AFBRLU__ugkV+;74KBO<1_mKB5aD4{=R49L9LO@$wEuD1}NUz18b zUm!+qU%nlL!<#qe|kWh*4L(UAhXigQ7ZvvFX<)B z>ChtPLJTHPvw%qSqz%HHbp0{3c*#(%fZ4nvrj*fpfeT>Pmg|IoGK$I-JMONCGn6%E zmdL_NI~WrzLM*I##X*~&tBCH z)tWTk(}H3-2h^&Z)fT=16=<3XUu3z3PJ?Hwo$5#~05j-0!Z&;i(Q0MlC0LP|5vgQs zq>@ouCE_S7zM%yR{O}YOpC%WygH4hjY1x5@QJykwlC*O z{^#hxf)p96v(W7Dx_Bd%*p?;lnaB#*@-P;s{%P za&4z`IXxJ%sbtKH-idtAS#|hGQ#^Kc8cNg8u~?=-TX0^S?li!P+4^ zLOG5X_y#YEhmtgsZzfri4+gMqVt*N3-r9Rf$XmPRdH6>}+?(|DZ^&4tT#09>=!^Qu z3i5@Jc`2wSp8k!W5(nh950A(1Tz&e&1-)`5vc<7DyBQtGE}-{b9HkG z{caFxJd2d}5l2rxv3E)S6lfYW2V1vk2C`3WDqyCrHVy4zbS3nI0JDclm=1AM*p@&| zH7QE>Q@yk?jT6l;=!E6SQsT0p5#GnsLnjmk{Z{WefxI{j(DeKZo=miOC?+fL@ zivaU$r)JxMFlCft4B}$M|1t*W{mlM9oi^fu{q;~yl`32w??yv_5}MC|@;Y|XtAI7C zdkb(gYp0XCLEg|34S;K-0C@UE1Bm7l7k%MN^yq;IG1H#|t(8%G0z1*>r2T07lD{{y z_Lp74I1Y-h;~UVav8#-p0Hx2Whh?!onHrtS~o!Ze-=c~J(w);1Y?+9 zdOzAwvO&HK%8_nPN8N%Hnm}+v%Tocc5~?sh2c29l?Iwi)$5E8dhjNyh*XG{gPazc+ z+YPBkNV;JLeu_tJH)NThn2nS0b16(8O~zc5+ofG3r5Oqdj>?1lwx#A8-!hk*E1@pt zBEw=m0^m(89|SCD8cCb|EmTJ+8s(9e)AbW{R#VJgl!%i;srD`04d%D{ zK;b*}8TL+#+qk`c9L#NQJqprue4i$A&u7L}FU14kI##_27mMXZY1D{L6H+@yx~V3d zHe9Y-H=dGexk;*V~!xs~A+#6vCZxBQK2c(#XUD5O z8j{5xS^&+MzwyXb~{5yb~_PJwDwFUd6% zFL53R(+Gea-wX03RZ_&bnm{knhtr$Lo#+qX+ zDUR;>Kzu1H#zO8Iq)(!`S((yVSwTK3hp3Mo%}I*s&mcwlij^H|g=|M^ z!IO?$PMC5VNlw*vjq1=3i7n4m6KBgau8ngiuXt~!3IdKrs;N&3xh^BMbeXfbxH=?6 zZ4y+Avz_w74AX7$bj?a3i!4VYq*>NUWVKlu8sZxYG2(`dxzfoY!DRb}e{dcL}uE!Y%@{H&R zl6jF5tjq#xk;&Rliw~mUu1-}}v%Q?vDfCh{KQWiHG|((rza7`EV$TP<%1X#Ttv>4! z>jAvCLD|2uGS(+K`6oF>{B9k!8aR$6>wtuc^7(Lo0m|Imvg$|0T&`H%TVrEMB>v6} z)3Rbk$Vj12vl$!Pn0J-ETMcttYrkeRUPdpA=&izdC9V$Qq}EiaEmayytvRwq7gT59 zb9`u@PUc}e41DQ#w5FDMw>xXl2P`v0SCU!QuT!P3bYielGgxM3{bgRGXAhfCgYL6g z+NapsaIGWg^7zDMptxCF!3EGY=n=;2gLEWoGgJ|88xRYFQSNoaI(h<#=0vX5C=;i<6RPLF$gINXUq6aqiFe250GvQ$zlh30SM_y}w0a@_ z;G3JHr-szClNM+WikV^C3FzpGa%Fl3e6XxHW3J`+gKyC3K6Q>wV7iHlhIu$^Fdmu$ zZ`xjF^=iyjjX(J2)>F^-W}Oj}wE=TIfIs-=Hqa%*SnW0-L>{@|P2NX;?L z9gmV@uUbsB0CVI!T`k5%OL6A5+Bova0loQ}7MXN%kuhk1*|~bw?U&s+dt)Bp zFOyyQux2>fj8`Fc@68 zWbNr0)Ux#sDoVuckAyV?KL+-&jo&zrBJzj_6%Bvw0yMA#*p;;GJ+zNh3CV7Hwi^Uf zdE}_x46maLq`-eLz`90E)v%akb#Q@1^v&<0rQl*y8cBd!lYmYVhFBk6Lblvw;F#b& zt(0M|0bLu%56JW)dcYJ@Rr5I<_~&{MF6omgkj*+z4K*L!%Q#$on_h|>!>k0HPMXUG zt`sB`YrhNs(b;N{`hi2=o)HGHaWI6t+`4xr8abZ}$4c+Hah9fzoRgImcl0;|1p!d(gOVA#7wu*CC|fz^tUmozW)1& zRB?DTzNO5nyi<2e0jG^{-okVzz3Nk-X-q^u@SlvA=WZu$Quj4?v}Y6JqrPX9bPP650was4GgX*+un3GkyXh;Lnwe8L^k{u$!{u>TXdx+ePO%IgXzE6xZ)q zCCc1*`X8TZHLf7Wc*&RURL4o&NE|YYUCKPxfJ?Cf(;4(DDx!3xCO^c6myuyzEX-}L zUbGN$FM1yUT-Bo)3*m{e6-?cAnCT>R>Wa#CMCYA|18D1M8ZPK+pyL8ZK=xl@b}V`Z z7X9VlVvD{tw&)9|{CA7ajuc&uMgOl6MVn%aR{q;Xe>8+eYm!Saj+9)f92-KIlAa=a zo<5~0;G;s9fGh-Yw+AU<@!Ezwb>xdWYvh5X2%-_iM z!Id80`Z8PVx;epO;$^Vy7A}OQ6nK31C19F_U^elvOs(P!3|P2sC^#L;i<9;$!3(2t zW~%|4-KnDE^Ul@j`2krKp%%dj0Wi_Ue;O!#TKPrhz=uz{5dMRELu@TWqFIV!-!k{kyFh?IwlpuyZg)HByvewDFetO+^jVDEqFWi`xnKkU*PI1_ zQVjtA=lXL%;Qu%lf@@BL;MGa75KKK2f_}TnC{Aa02=ef&7n191vF^Hc7Ci9@AIwMS zui<@*E;?AGEW*vXDflx%qN9W{(8b9D-(`wdU;G#Cra@; zaAp6)YuI78%RnXo1ul?=^bf>ILm3;ac;(PpsOb}S8Ad9Ui+iriRPO__F0e>_vv#1v zDB021lB6g-qcUqpP>rxuL@Ny@u#m(G;0)`5fqvN@8m|7J)Z7`IHe5Y?zZe{+Rj+<` z1P#(I#rFQ1EMgDwf8N_P1Z;#g0h|Gc{3^x<#jpmsba(hvNdlv+Fouo|zqK3$J^KCNw`F-3>1Bsm8k!AF*sTvj&jT&+ z+b);<7~bX)AlX&8xMNXpcRGA?JU;r|>=6pFNAF4YpjD&T9ZRd(qx*CAI2vM)uBt?Q z99qpDou9MEo)CMqSFw%novYcyS7pY>TdUb)`{(TOT8KTiR3+o%<<;!*f^#%Jp1YAf zp6+CiCq874$M7^noBfeX*kd!g%xtqiyo5a(yC>qK;h*eLXF?W(p>elIA#u*&JBPf!?XBcwFM zLT0HUy)_-Al(LY?YDiDbHd5NnLQHDN(VFK;X%`FW=UB+0nqQI9P8QOmhU}@SC#CHy zzaWq#947Wx8&)@*=~ z=lD$%S;*5Eawmj5!EcIVA&+6mjS%t(zX@%RNau^P4_lA&nSv1%x#4oA$Ag zIt8Ec zfvW5oAYpgba_rGfc&(ZHsufkXWBjHobQ%+|zMjG)Y?!6GNb75U(^&PsSr%&1`kUW0 z$QF4i3q7e~x`*HNIScJ(p<16FSLh~Ycx>v0I@^=hghG!R&zJ8Z^5tC*ylAj6Z;GJ^%Lyxnq|cPG=ANHeOCDWIih=vRNYE1>|Yfx`!tE`BO|K<`*q^zAm@3;(<9nvn_``p}0DWFqLrQk&n z7vpcC6fg=GlR}vIrsBD$HoSy_Q`VZ;tby)hp4(FA_yTydF>d*7NF!~2YJ5TG%`?e` z7NR4?#$eTOu#ij&E#5^&8PamW>mZ9<9`@!LS=wMOO9+81tr{%E@$c$_7Xs4-&p&(g zJG!8O->Q3DE)A1XJXwSeS_jqYCfE*c9Q%OLwtJ0v;PAME10PEJa^($a(uNEUlFN?4 z+EHzV?1)HkGBiv2V!*p}zOpt0($MeN9^N1>Rr`~gMWa-e_OVpkX9AtPu_9YcT2@T< z(s(qmGU3}G`idw_^V0;VcT!HpT%du%th($h!K^IJB1^Mcby-T`+$}mbdp2@sbjIIj z|684r|7D~zX8&K<8OM&Fvol%?vvpc;ghoYrV}E>1Z=4UKYotS}ax6l!RG2jcJyIo) z`Q9G6Zq~omBU^(1H?zmzkNpVt_}mofkXPgWP4;;Al7Fi+rv3B(X7)G``Z4UGBt&}S zW#j(Hp2_@$OeYf;}>$9kR{vZ?ebJ7ynzGVf^C%X7+gX%OArY7sp3> zat z9APz`FMWYkcncJ%Eeoowl5B%^)o+29A*ycj8L#k}R0>94B3MCvrBJo9yPfI$w93 zF+tzn$ZO3G2j3>O;mmNZyx~WJF<%A4JHJS38#*f_g{MIxHD~eLc462`1V%cMvvy1j zF#f8?_-*Y~{5$?CIU$0gaPZFzuKfz4=uFto3YrXqPE)OUMCW6#M0-Cg+WWl!pGIR= zQ`h{QwMk8F5l~u4?Ot;9p}&;s+|vfXa~^@M=28AfpI!xXEEAt_;rss9h1} z;q{P(8MqC{`@c3yK0r&SbTVh{=q7&zf@XeeXO%oIf`+c34;bmcODXjMwTlo5==Br` zbiYQRg&7{-#!;jtsZB_Sk41=$mW%}cvE~!en&ons-SuhpHw)9X`e6`eg23?SooxK=ZG6bMC}4Tp?t=LX|b>6vds` z)P;d{Em=*c=(StX*~=g~S$8UD!|0}ROWkpu@3XkNmIVFB09fNBb+CYD27ga#`@DI& z%oFm49}DI;U$AZhv_zOG7FZS1c-B=F3|0WN1R*VDkX8Y<+%W&{0{oRZ>C>-kph~B5 z){bv-Sn7`He4oVC`4jZJk+(3RBMfgXqY_yFKR3~x1Cp0NSClBhof{NegxXqw`;hej9AH1> zL*PI@797TcV;F)MOxqbudUjRW8Bn3mkNwsTIUmo|$%*n3qrAk7mNHHA0YOu5q*c8m@ehBDZWMk$Y?u9uu&o>k zTR0&GwwH!tV7u@A(_u^cv9SH>Z~q!>?|wG|w%oWF*!Wo3KKk3~u-)@wjGd1eFIol? zszb{z!ZoEf=^A{(n5NFA=Bo}0qw<<`O`Q!s@mL;&&J2#4ze-4&Zx<5sFqwFa>|flZ zo1Z0|xT;0eEpF21=QUz@|4MC>tjS^W=5^1%aot#uR1!^>x;Hi%?K@t~=gaqYQp6?;q8?Xl;7#>_4hvJ1QC_ zWyI7pncvz%cA&$By7}3w1dEGYp3egNjfFG{}W!cEMjNvG>VEX-zG$`t(`r zl>++jt9klA{%2}V(dJer@6S<2(Z62EHpeao5Ih~?3@F%oL|eukF_qeJII`=mF3CRV zgxt9@d$3!z--k(x^@vr})Ly#@WY7N*WO@zOyS;6N@@O=JkE(h3YtjLD3I=eDOOeXv2Z>Qoc z#spYuDx59bdmHV2{5!sCu8!lJqzG?~`RR0Ky5-%F;sGvE#IV}bGBnR<;yCTta z;Yx`bgjo_b2~*it_gMJmJ^#USrkMg>^~D%yP+xSx4e*;DTo1qcU`<`Ea*2?PhGb+f z{V8A~SVopwA`?!zFx}(3ZxrSq=z@ zIys)7zr_dc}8(U_6oCLQUDA6tOHY& z03Pax042{v^n|ODvx#2f^#41ErfA@QnrQwQrxDF=ZDhLttVF{o=82KgWX_ocbdyFv z*=G{aSPW7=0Vcfg#a!+x_}c`3TjB3b`0Iwhz>8<0pD!l;IC=7H=l9dk@4lCQ?v2P3 zf(}(0e-cYUf9;D>(5jJI(a&F2DTpvoHTp>f`tdGJ5#j+4xj_Q}B--ypY4fXj58ic0^2~MF z)uK69&NHLeP#-$r)-w&YEpG-|wgvlp^$?wi z2Z!>^!R+~0u!yZY+Xc(~dj#`*5x8-^V4S~POn@}#6R0A%1m2ia zoxJ&X3K!28gvs;Q3CZ*C7cYkhJl+_b0WWA|8k_Vx(S!|f8-secVY!?) zV~Y;0KKO0-UP@Y}p+Yfr7{6`}zZHS3cto%?v{alI#qf1MgyGO04Z{@-#UHrcXf?w> z)WOh|@53s-_s8J7BL?4ZW2Sq7 z9^<93Rh(2MZP3pV$Ko|WQH%%JmR9%*aWclvRb64IDI%!9UkcwQOp?O43n^0g9)Xv_ zqL7G&T5(c%xpDhiK^ZIuP6T7J;5gN=_A*#tVi7X75qDevjMn`ck&HCXU!!+c7rTm1vuDv6%G-rgx9d-KjY6I)q3K@C6Mke9Firh7pr>YF~RJ8_GU(9}|QB2UzB(KYmlM$ie; zTNf_q%YwwhD2Nb@Th<2+?&ZrH7cTf#YSZI2c!lYiZJC90WqVJ5vst_jMitpX#S zZ^JdDw#WM*j>X09bka_LHkRob8DwdSHxEX)6zRkex;E)04w{QTecII24|E2Ns1%}x zvLU4mjlo&C7Z3Y)7eh%ejKvM^G`uR$@PbZQw1EtO#HIpsYY<~ZG1nueoO zYd)XwSaAZ@n*#Ob+Uz~(SHMmmI0$M4uVhzb3qQ%Jn7g4Iw=v3l0Rq)j)aj1*+WX}4 zkkgA!2dI<&>P=jC0cBX6l~W!PE=FMOeGFJur;{2G$l=Bn&K0)=CqpH{@iB`pYv(F! zvzUvxoGA}n!#1qZUCKQ4Lns1p)YHFV261#d-RaaehP7fsOe>NB_5$1AANYuYU%5pW zImr_L$nZ&)-WjnRROZgBB*&oZfQ>*~6Dhx~@KX2k8-bnHx%q~NRd!l8i*NW7dfsef z++-ceO)se2^c!IR0N(PM**wflU&J!gYe^b2{VXBMOvaBjW?H8))9YC97W{cBmYLGc zvCQ=6BN{WkWd2@eDoWOvX{$NPOba8-l%pC2SPuXfDd#Y7oPI(ds=YHF#nm4=YB+Uz}9fE|s&rV)FOdwG?-(9wr^g?Kd?30KJvIQp4| zYA@N4p1EOe=868oG_!aOapC-0F70A&=QmuY@&-XKlSh}i7^8*IW&YKQkVNc93rJ~~ z?c)djK|~4@Lr1SVJ$K6XE_G5?jC5Uk)Jb;H4g3hMTA*>&ya-oebu+vbDb5l_?9n*O zgH_oSVKlLanAfs4(t4J^yKa37Cnf@PyUfgx88{4Lfysi`l3cU( z0034cm-Z;Djb3|Cv6H^PgE>M$7VrVY8)ikh#tGF^^JsR@4zc$dfWd5qa~UIdgNOq* zJ6|#>!Sl4H0*|TrkkfuM8V9+QoYV)hqRe)Y+CXSs8WFpQ zFy^Xc-1WldAL6cF#$Eq9Yh4rnL#(yJq_I}IG0Ivm9{F*s^;c6YYu(VLv6gex_p(-R zWQBF*s3>cF6k)9c=U}bSk7BLRSy(Id-(js@m9^+d)}p7gR_{pG3K3U&rfY8Ieq=4+ z0l6JQuSEK~fy#+XkDMcp2SkRrm z5@(#L>KDv9p%C;28kc9=^}uVy5waf9MzrFX@vH~Ovn(^?Je4CO{I^o$zY^j&iUVrL zhQ&mWmoapaT$8Q9NLs|Pb?X79IFg{vf)j!%N`jJ$YbfPZ1qPQu-qi^AaK+g<-RRj3xOdTfR{oS9d>lU7mJIphxC|li3ieyg&!;dj|!fgtEi!M#Pi@8G( zxeNaG!Cx2r9fdzmr{@eXpT|>|+JX#mS-kmd=6{t(0fwnWyC;x(Gx)8Avz@pTdj-9< zjd>Q4E3)`)e1nhp-E&t6<8gsxKMjs?$1iu!Z7a-be<`0^0fAhlY#a@m^vaeWtO5j; zR>kH2auw>9oAp3GR)xZa*-G3uv8izOeBjghdBFSepx`GjmkTq3DdJeBjQ$jr(IPQU zc4d&}66oQ$a+phbH_UgEeRTd`@nUCjEWC2?>V53lAw|JO69byXJ%ok?s^e0;j~8#k zlMA!h3W{d$xES3g<75GOeIMRgykn=L;9HpfYOng%N1w;A_DFF8hzO?5^}2NoB_+(0 zWLOdVip!jI(iSG@q@A-s5Um7q%Ru`@@dC2JRJReBCjnsn0=LAA%uXcJX`kYm5yZhy z1>^i8afT|wUeJxq@>)!mqutE?q${gQKYzBE2&l}>m)Y0tf_eU8K;_*~)KbwTRizEI z7S3Mdy^|3&8_H&?GSVdbpZ_dX*u8Weei`;9L<1*-3dMSF|M`Lru zt;&Y?wJaclu#twdcD zOtGM`e2`R_8tva@7o-5bcGFK#B`7dMo5&(#EJc(QdmV!?Y8?MF8#RCyQyH*hF=K1e?);l2dEiFrz6Rs<@Ef8nJ^Y zKs$&^W5ltTuCTDkR%?2oi?mh77+n+HB3akuFg0`tDcL5$LZ1=(<|O&-=eqm1M#wzDNw45EfA%b&^+3)TF&*=hv&193~gZ zGjH}&Gswid%Zh3HI4E#O-L0x8`SZ}*&jh^WLHhg}|H=3}hO!OwnLnVQnRJp61d$UE zRkv{_S1C@$EIejOykl>+!ORl&=grm$DKu%gv47dBC@LHkngx^QWO6R5_&#DcgD%Mh z4KUR!j~GsBDXu$r3K)2DwT1|0=;`%FPBd=O1ydE#%)etdq3=k`pHy#1VTFIx7eha1 z&v_pD__!RaefnjN1L49l!!UBL%qd!CGrD)Mn{_&LBv~H7ou(g~HWW!)T!Id0{mg~A zu!3-9WW6zGy-7^Mi2<=&gatus=6aLuRE?gTa(U-ogHCaQTdE#a6wNYEr^_O`3f+1W z(M2*Db*aH5+bJOqr_p*{Hr_^O8MfxayD!iS4I0CyqUBa$ntDf^p#=tSTbGbuMrtkm zR)fn)9~xp>!sP72G~r@tL+T~sRH)bGzJ}h#V%U5D*RBd|(7g+eb382;=OfLd>ySa5tOG8l-7!lGhn>>-+^)SU;cd<*S;2k@zkHs0poA}{BOY6 z`^vu$V`CJ?veRHh?{olW8twlRx{7yc%%=CZg#siHEQ^b(#nIIcP_j&55H)Ji%G~=KT~TOd3m6qUYG< zAwmKvM41^+3eCX$5%*zZIgUGv^DyQ)?AgCCr)0CLUqD#CYmC5>b0^2CurL>G%x|4H z?LoAr7Z2Hv3)ho)=sM*}C!G|5@Jdoz8++&uq83x*cIHZj5!H*7iyScXl)3AmADV>%Zqk z?bokFy{JjO>3CNp5F0aalLW5jC9ru)CTS8b|Sse*P$M~--g)3 zq5&k(@PdN}w=Wi%H{_%3poh(E(HUoVd8_k)ldYAPgw3RxrCr?L>=PD}9J0cgc}kvyF48^925T`roP_?B%w;7` zYI=puFl=EV$!%F48WoQ|u*Q=3&Fb1qy!*T5p{em!AF5s>NW2Vhu-X3-c9;otz$6zv zgg)m;D42!aSw^eSgB?0}I-eBb_?Lt?E0odlB-~7?c|pC&Mlb*$qp!z(P?GA4)2*XT zLos+~M#JdoSsdl0t1+gd1(a8uR%!v1}qZq)Ok`*){M!xUG+gR1!fS&k(`H}=u zp-3thJ*X(|QFImF3xtOy>^)Oq!fHi#86;G78i`b$K_e|yKq8R;uG<;?&8jxpq0n1$ zb=*6sFH}8)juESDtpd+)gVN{WXP^nSpz>GQ>}LbJ=^^n|blFJXcolKMz}N#|Sd}8L zz1>;nwRbw{b+1O$V4GCi&I$9Xa%wxpkEPl}oN%~Gs{K1B!~-yYN7q@0ob($L6a|Uj z;kPc*RmsU%F~2nnTD0efTeL?JchcFfVv|&5L}A|F)tgNR>2Dr}7>$5S%E$>IxYi;w z=dCmjyM;}W7NA>LP8=KK7gjdN*BXO)E^3kS{xR=5nBI~_*U3p_(IR;?H9mqjSY1hP zd<0ki(9do?D$W%VHJW`vO*+v3S4MT%$faNWw$a|TdT-vlQW(9$_|y4q6Qwr2H(v{A zPAC4%W)WZ%4^>tS`{LTcG5;+7HR=4;_I{&djz6E%;kJhvPcWc$P?J#ybCU8FU=$}^ z^B3$REqkPqGbOybY9gIp9o2I802wovAmuF*UiAD~dBCBd3)m6v;LXD}{MZgpzc`t8 z|1?_2LyGNQ&ScFsgVRA-am)f88f-#|;NgOfJ*-aRq4)&MV=! z#d{rn#ZEvK6SDBZZ|mZ>C3rXLVdDP95c-1hk==m(%yvN55%Im1b+v<>kcb!9sGfU? zzcMn)l?QbH3rN>gy&CEM@TSNZF}^A?MmWs$Hk|SP2q8YBDoSyxZ&qB6{bY)I-`c@; z4OMJYM(Up8PG6DP4k-?{s|m_m+$q&g1Y;S3aiVTP7`=w_p2yWoW_N_(^C2BxXfQ|; z&{j>njFpAgASCxeq^(_;P)r|CnD8noDH--1Tm3L}b1+c~>%^q$$Kaj5vld{D+|iVf zlPZp>wp^IcRS!aXYGgCw>QoF!fggID&A&;J5fUc1#vx!jQe*(vp=YMWIc0n;kBDv?Z-K$30c_Sb)4OKF$ja%H*WoM^c06z*7Z8oQ5m$P^YG9T6elDTM_&Zq zjutF3o{QX^z`$Zx2S9ZeoQ}jgxW%-1```)|F&EIZho*1AeKVMK(PMw+xc;umz9DwA zf24tL4mYsab!Pikg*cSz)tzj`K^dPtm3iEqT1{WV`a-Hpa}omGFnOda;Mx#}2nng~ z&tuvIbUoY_bXeTS92UEEZav6nNp%yCpD3;c?NbGpbL+8HS@S;ua(^VF;R zJ&mbWpIl)HifG>HR$L{Zbkf_|qFg%A{RGuRHuUSQc$>^Ux8ez-;3AYSwj%RdGp+)^ z$?nG!=P2uNimI$Lxv-3SH8fhlhd;prE>#PNUnCbYHIa9bdM8;_ zK~80E=)#=a2trf`%1pzaG-$e=w*Rb~#ua5?lDZcPC;$Oy_#3rQYC2goU2 zBLwEj?90|Jj+Un58z)W2#2jgc8DVx_iePBWOOf+Ta-Lbvv!Ft;8HLZ^YMDZjatjlH zJsi7tn~pRMC4I&Q;N3(pYB{0m=VonVsMRr6}E zeY=yk{Smz@yXe}NP}IOMFN>-I;nNg|q?a@Wf^T?^Ne3UtH{d-YOp*A2DH8jui2d() z-KMJwbabYO>{7TS6X(n93v`trYJ@TR%DHd`+RApi z@sEhK*hQ@uaa`HvWAeg-M+{^h~ zu;v9bz7IjFVATs2d>_R38@=@>@wp7rCiC0!OkVR}*J6g5FPNhN0tTe=+ZLN3=P0lF zy&4QifS1+q!p=XM=6AE(pAz}4c{(KLY5oYKF!foiP8i^}ZCi?9ws&kR}X}e?>(lHEUdaEx+!vR_uPn(9bUmhu&*&arGKmd1|L-+XVGQ9!)f#x z7bo@u`?(Y7&&nYLc!LZ*5Cx(ES zE>R=tylDDhfv02r#kOPy=M>_XKDowE(0|^f=KuDlXnjvS z9U)2$I}5m6;NNKra4SGaTfhN33*%|aO;Iv-Lk{f6qy6$6|osX%2A*7A#*R%D5 zdW98oalOJynNqK?VkY9#$O$|?^_-Ak?EnQa7zaP39p(qA^rqoz3@Tu3?1lc2hK9ss zIzI|{@H6NjliEQwz;{41$@Ki;=y%Cp=wjK?jrV(Bb4O>kTzZtKz1_di0ysI~qIiG9 z3N$`ZZvsQ>3>9m{ag-qrirlp_TIA?57n`B3Sivq2i_qrxUC8C44}BM%N#mU}xUUgL zQc~gpCZbY*)4~Zenr@mf6DC6SpRgS*#~^4Q^(Pn>o%JUe7IW)QFf3-&pJ3Qa!>5rG z^UZaQPykVRr*)xarY?Bt8DaY17?iVm%3zfUmRo#j*~W| zaYC}PHjV0PA#GI}w*r!4n{c0O^HH?mu@4*Pmi7K z@>|(O%#T|S_`9b_yL8a}&_;QToM()R2Q#LDSnuME{!b>>f5LHf8W5#*Q@D^=d(M?wAlJy%ZPh$AO`wzmU=IN zcoCgc7VQrol9mH64N4^Tv?P1FsBhGv#I<_8p+Q9{_BzbPPKsAWyR;A|eFN=4OEaXM zsh$If<+LOR=1XK3%T&;zDqP0}{Yks$ z`yWKKRFTqfeX9xD>Ylx#%C=t?sXLQ@#MctD)>b)R!|xarjp&Qc11@oRcn;RF053pJ&G2|KA1E%bxD4cZ0~hv$&L^Lj5jEl zU)2>?!2B|7ABNNRzVIbva%Zh*ss%-)L20vhqpNXf+ajquUh<8?YXQTW)8+mArU%)Q zU1>MJsfN9E^3qN4vg77>Edd@j^yVdE%%4)kx%Vz$M^JQI`SshDs%Cj6mjc#XINeV;>U zji`m(s-~)0rG1x3ekXpHNoI5$hjP! z%wbmSz4XfhMhfmMB!(4%@oWLpH0(cyf5*gcYqt4$j}bjFI?VJjB@Zu z)it^(q54(V=OfcVOyE3LS0v5YGwQ-L1B~F4wKQLGr=`J|05Xe4&X8u;>1i+~@MlcZ z8Anh6WuPL-=bJjflk1VAc*xsv4Q8 zMNVUp7#bY6LuHc)Jj{vx3C1Vw=%!bKAak>A)oK1Cu1? z%caO9bayVtX%rr?gCY;Aahlh$hdw-kEw-8(6yEbVTB;{3XX>aThJgjhUi#^^Xn&qU z3fbVZ7YCn>x#|FehZuZKyQQ!l4*@#y{NF14SuG|tIh0^L{L~%Op=X)NMs{+cFjpIR z{A_&6ypeyT4G#szA|Z)R>qgd@o+h z!bin~ALoX{k8#7{M`FSc$Aq`Ug!^N{U*fQ4vsUvTVq$(7lj9fB@S0jRtY)M3eb*Z* z**DkRrG>22Qj}}o*GkP!{B|&D?ys@Eq0(=2jZI52^9_|~n`>rjArqzMEc~7X(mWU! zrA&tKT}YMFM^a@mQf2W|(#c8dY|_anRZ1rk*JR#N+L!pM&ahuD?W;fOOyRhV7U)>( z)oRBE(B@`GaSF~D`~F`C|DW2yF=8iO3XkYu?9Em4wcfl6EvOX1Fi_qHlIA>=G#8Fl zwb|dGH@Lm@DlI7~??YL$m)=*zE~kbu)w|9VG<)+Eva2qeBb{QB?R_!i)(3t9IqOd` z(kZAvh5pwcnA=o}>fQlW&okumf427;y0kjDz48?KdLzHJIWPsvaGL@X@i8i3 zVRw@q1ua?;yGoq&3$~?d37j}NG90g84t^eMX6Ch9T-JVoAGbV6t6UqB%ll+;kl&V6 zWoztwBcBsS!Alpsh^s+az1D^rN;^6q_35kRB)qvT94~ejL)=DCf64VJq{t#WQqZ>D zLent0Y~Z&Us%#H-Yo$Hdi=|x(IlcqkR%(TE4AM}dco=tEu7X0`232_DChn^i6z0LN z@cj?)ZtVc%KjQZqy?9G<@B^(eW_G(avoP=n$dNI zgf+M5(RJ>rc&vFZ_1T91Zl`5cD<@&7loIfJ-;cEKP8&~ z@|gUIr{~9sD71io2xW|t;mJRi%V~4z_rJ!KmjDVJEhIjqC_!`-$Zu`LIUc)TWZ>|5 z9n;#U;Svh2djx*POfbyW1EBrBgR3jqn=viX@sBi5Yh6(#jfCoF%gg<5{I}%i&{V0 zYl#+qYaJ$V(!cg%&B6at%Wgx5t6?ShTlG`A*{Gf@V`aMaX9Z??B-(HmXbyW9bDN<3 zzl6Wv!rxo)_c!?KgTEwb|77@^34e>e(Qs7X``qw-VmIWovz`~Qug>=h~ESo%mO^hXk2)ANscxZDtOn_TqeC$)8tFZiu?^sZec4Mpy$ z8w|!*;pJp3&ns%4HhbtQPSY>i*o|g*FWGkWjyaVTo)XT}9 zQ!gibTD_d?3H5Ta$JEQo9#JnR+pJzr_ON<6S)+P6S%Z2xS=~P;Ms6ju4Btw|-h}A= zWGkZglO1F0A;qaU5bH+Rav-H4hTU9u`8?kam6(Rz@5^cVdo+B9-k9kU_0%yvRUl1vT0YNPnGCpWwS8s zU6v;lb6wdi()KvnC425vaJka0xD^@HhPPJWZQ{4#MOTkjBSUCcncWm~m0974tIRIb zX)MFi%^fBWe$)BvKC@mHah^sM{H6pJ+QUNQG|EsFZDK#lLcdmfs7qD3iTw}@J*-g( zzv-_mbPo${iBid*S?Ep{`d?8hd7Op5#X^4_r4osSzQ#i9H7cpOmjDymLN#lUI%;ku z_UG_x6;eh`DX~9+Uu8%YHATe!2!1U_im1sU_J{GyhSX4#N$d^yH5)0R=6vE&sX)Ej zY<=Wvvj_nUWrih%&Gd&P1qnbw0w_WPD2koww{=TxIMd&!*lK63xkC~A*g40L@;de( z{c9Q08+Mqr-w+=6O^&0lX6(8o;>DiKafH{#Sv&HAda~R0&AOx-qb5M81lTq zG?EMZ?Spz^4-4@FUE@v-d`!d)Pk+p!`vv5KVf{|(1&%lbgBk8sUB3~v{wyyJ!Ly{^ zbh5`u-@t>NXmj#i7-7cLBuqY}cWhLY(@P#r+Q`A!vdxFTf+>Wn%n60H+7*tR@Y)q} z=x}-1sjQn%);YaJCS_f=a~a+Hxxy}hr=PKx8FjS^hi;N|!o<3bSsdOb8|0xZvd>BH zLtk(DIlju_g8~+;D0D_eAC-qruV_`IBI6g|S5XKnvY%N|n(Ah$EW090ya0|sale%m z-r!wtlsDW!Qps-55pfdz>4NA!$>@4Kqs6Q|TS#Fxp6e)_kCuhyJ#uZIkOZtE#N!1U_CD(Y zYX`B1lJJ7sLAf+6B%-I3IGdwaxEIS#zLW_`=4Tnr>uKhtY}KovZbKF)ULe)>al%E4 zNWJzxNaMBlILk`tjvSWe61-N%F(@_br2Sm*aj2DV0J*5N8n{5Qf&@dciufFMVJBWg z)12hz8+2%Ig*G{)B_8=A4m9*jkHST600LH24a{pndF zkljPynZ*#9#~M5tSR5@ZiGz~T9=!L!uU~|2B#P= zs$XepI+=v8Ay%RW44K zi!|R)Vs&wmePA+hQ1h13}ZXhicE2B3;kU}FF&2NMG&uysc7aCcI zA@HNwZ3FbH*&J7)9*?(n7gLVhRJ^NON|65dY*Gnzp*N3 zjCiv*Pmk9{Ehl>+Ctg}17n zh_1!WRUd)IRdYM?x$0{C*$F@NwVx@<9!w-^i9U%Y>c*e>P&fSrCW;tfuoBbgv)D^` zAA#v{fA+o!}ZzFoFX@{)*FKYB?6$2^MXuMJ1x5?Jdd#_QIPSOsvJ8*aj6kzC-OM5*B zcsUKDng`HTmK4tAr7+ta1qiIZTskLNhi436sE~Y$T-qZYz%yDvW2HxPtq15hh@RHg zYX43*?fs-?(a>1_9Y_@r;?vru?M?Cv39slH9ZE2+X?J6}l3kuEj7Nj4(xcPbSo(yf zmd5g-VB8j6qkX8MgLx#HN4NZf9p^cZogWqW1$d3WctKNnO5Jf?Z~{DoW3tN?!JJ(_ zB*tZze<$c)McaSSIo_wggE<) z5S>+$a!Y1;Z)SPlwSfv9t{2eG!L>mU1iL&{!;YLHHMbN1O-22np*Jp5q-dVpoEmCx^Tfcy9D5AR(WKiq~xxf zhjxWmb8C~Ot94|lacu%wYYKwcwDvdIQ|9VQ_^mzw*JAY<{L7^GIhQk#iGAGW|2j_U zo`P2M>xM|HzdH^OHsGo}j5ybAF~Y!1%-dy0pSZ+O8n!Bi2OzAJO8aq_rpb{~cMN(C zPe-JBokr;@&b!RWKk6H5zpxz*M!$l%5}cRdvKfVGN?G7_rsRjLM@V~tTt0Yk)u7@S zbmy+fY&Y1$hGsO{2f{f(?0w$F-SnkV8XE#Ug9E_1ebemJdVszP;W`H7n9@F666_@l zw;M_A;IuZnj^zm(+VPOKBRsCTt~Lc))F;MyYN?=~bEL5x0iKGMsD7iA!+95LFn2)= zf*nNN0?oHlBe3^4i|P5N*zy$X<`JTDB3*wnR#Zl%uXwTuZKhrNiN%5^i_W0l_KZzGw$gg+9yX|dE%QyTf z9(S5Y@g5_x{Sm(5LFDm{ce!1^v03{`!|Pd)W#9`Kd(>Cy09QzM*^Y}ngw+f4v1BsOQr3LF;hs2-b0-2t`YQ(dWzQiD zhT;D2C^o_utu?qWTF!4Zd#>J|&+%L5dai*dIU#$FLOTB!`ZBNaauqo$oB$wQz5;D3 zVNUKOEE&Je=$Q}MyhcyQcvCvsW*ieFx%>7-kq%VDk7ZpquDC%y^K7(*sd))!rB+;2MKFCAsU-7|uc! zKo8r7=^-sb4=f~C_RuI11fQ@TQQJ|X^@#jrZ-1m;)Gnk20GSFFz}M8G!WOs%G!ffj z{-Kw#p{(QI!Axqoh_w*}=5o@(S*(FpA55YqpN$k4-Q=w$F*t+hT#p}hSvKPO|ZqfwQcjdSoQoz4*{D3+qtFZ5A`wG)75uQ*|Q3&C7wv7tf2w)32Hr} zW*cUVLw%<)#wC!osU@%&C&7wNC;`^I{)bpotqK_Q5k{T3dj37^8Bf?(L=^K4xLFJ0 z*e7H}#W54aYGySr*=y?*yP=9ImHS^CIU3ODAQUY6F$h{XGe$V8*@L6W;b5XFoM0l+ zMLeCFyq%_rVdxLpvr`Lf{{bP~er_Rruw+;Ww|}n?p1Jf#3E@M1!&0ZK?{p!&e8#zj z@W=oE3gI81wRbN59}&XqE*>d_&p^ljMj?Fk3?Y1Igb+UTJwlitA%w%+*@Wqvw=7{15t;Q2mn-h9j6gdbAr^bwApGPZVYyq zs<#RkkOq~hpWlvs%D?Rs47owQ)wkIHNt~fw)jVuooQ3exBNi;*r%BZZ@#|4_vSIC) zpS)?1O)_3*%Ed{U(X#E)=hy_}ipT^be#8Xh9X7#GNW~5Hr(nE# zy(>DwxM^^Bf`O?BtFu6@TAN^~D8hg!hHf4l?*9w52?i!0Z)+2b)z%IWs27k{Yrn3+ zJ6!U({yfqOUG;hF6ywZ_FF$P*yJ;{oipA8t0dHMo>CZC7z$VIXt1~i&Fwef|6a%Hp zG2@8IMYq-iYyS0d?bLNYc3dkiI&)lmHFjK^JWQE44Mr(5@GEt=N@(&2p4Vfi3*SaZ zteXe55$oo`(?+c8f2fz|#*S4t51u|&VH!GiD$|Rm>mw-Nq6x-c(1b zn+8uCsW7g@N#9G4j8uv^`D}z$64-&?@{xa4NUk_PMo6k74e_$^EQhPrXX;P}s;gKa2c_p9If+Evk*6-B z$llE-@t4gJy$_#MWn_mYBa_@0t(2cc0tuQ(m+aZY21oOgn#_Dsm6<3l-P~#N5@bP9 zL|THJeDWOne|}U>9>_;I*~~xm5mSqqoIEl@|9|-8>H7cXlT80lQ&ImqCXiq6QT2b; zq`>Mrcn_6`5gGpwnbPex||51%|uu5LcbW&q-OXPW`UAjB2;3Uz;5 zm#|p{Sk@ZLH+&PT{hy=3s8+qjwEu@ss@ngi?@;YeL4n&kLg2FIKH3!#xU3)$xNo0D z;O-?M6u5_&wsX3`{S6A-u86>WQk9tnr>Xs?MyUP$n%b`ntNr^>>V`+E{Vium-7Y3| zfAizi{vu87-#b$6e*>!J8^$BuU9Yo+$%+4_z{T9`;ys2B6{Z5);fTO}_~dD7|K^iX z4qrDZqV~shEsEV8O#U5ZVs{VXNnP{w@Hh zIvZ7ufZ|wctelB~BybWtWu&Bf_+&&<#gy>p)QFh1#)?@jZE%eghA{xodLWhHQA{fk zE{p(-Xxm@}V6?`dX5+v~+cB{l2avm!7}8ynCckw~&RsUFcj345Yz;M~o_j`c`4?

U4mAa1-{~N%D<5UHW2*|Esta(1_PDcji9%?YH{boWG0pF@51{Ncu1XWRXe@ z)&u<89RpBSCt`t-iMo@#%WJ#<(6u%PvB}?ywzl&Q z418#K-*8ZV6bVf_xn}K{)3n>*i{3)TyF^j7YgHoH+W7_#((ef`t2uLsy#7`{<22i; zHE|^0h)9tc6MKskTgw_F(PPjw)&}XHM#36R*ly0WC$X&uDcO7QGnK=VT4Z&^U3|S( zp%*I`OlnJlzab1!v~Pc{>a(Z!s@hs_ zg!381^a?yV{5I39&{7dk_j|Ca6)S+T$DlcenSXmgFcb#I0GB88Zy(2Zy|rIzO($%Q zZ#%YjLFTarpD-0oH*@hC!d~3>R$5s>I_ZEit!>)Dq-G&O4MBG%2gzP~UN5@``a`uC ztIx*P7tia@oyHc^w9f;8nMdcc@(+Q6#)hqd{VMgT zWT(8|6+?W*%Usm+5!;syUil*lw%DsKozUmI+5UR;5}&U#6eZR%26KXIJyK>>y!V>O z2iZ;|K`Ve3xpeSa$!C&2JKx}!S|-Z&uwjxdBwT@KdG5gT4vs_=( zhV>}bRF>yjwxdrK>jBkaq2{u%zBgTujbQ$Lbj(>U->?bK@79{hm9p52T1yRlFOuzj zQY#G3SEDqqG)kv*qK%ZAyq=!#pu6rhN`4~@n2h_1+dSRhpcczulaF6U!iyIHgdHsM@(KM zePxsaMq6|3*FpUnT|R0)Ut&Mi_Tn+^h3^cKw0C5CH~)5vT;3^++A#(1hiebU?MR20 z?di-DQ%|N}wjToinvLd2jzb60Wa;2%NnMbIly@KeOfK)16#d%KP+pkDV^p{IM!+Vn z3vkwcxqJ)EXB#ias-dfmHrd|pU19`qWyenMl0mPzUUo$AYqR&QizCG*5H1I#+8z%0 zhfHd-A7wpKx*Z5rEJZ5tMA!&~mxqbi>E>L^oHThRcA`rWkHXZhGz657PEpF=l1qK`m!D%#LizTPEDi#x z#Yv}>wU?mN%ucCACv_^$68i8AkV)Zj-$=VbcFED~=!)Ct>0jrpi9_kR*jZxjAOlH` zFy`)-9Y<*iWVF3sGjm;>&3<(4h3MzDMS-kPtf96OEzC*>IVeO-gaX#S8#Evo<$KTr zBd!jx!wQaGn9jl;-^puw2Ii+UMva<)%F1w;9%?cuS1llufEQ zfGTrshMI5WB!DPcnol?}LGmkdl6V{qwloO=a@wacI4QQ^b;`{;7r8eDs+Nl_NqJ`Z z-W0)XyVtz#yqY*$v1wfj8AZo1qfXxr)vDJoqGQ7rvY#w5*_If^cxLU}qTb{xFEO>y zXK=RAi1IXO=(prTjuR~r!&c2SxKo&eX0enLF4As;PY~0WN&f3-Vj|ji29v${165Mk zhzCtirZby*{T%hikGw1?JZjx67>y=gl846(li-2gKxe^dmD{4+JA#{Og^6=q&Wl{m@6oq*51pWAoP2`5n&Bu|o zcpAb?Z%qd7&Z47>VGw8wK3;cU7AF|+UYa~hW}az(7#E&DgPahs2yu}INXX30LwC4n zn%Lsj1^eBm;5XIye_0BLr$i3n8H?xv3*+nB0gETzV+Tg?fQ4wB;|q?9p060S z3=eF744qq9!A$nqO;u<+f7^E>G0pN-*$QU~Nls>4kC$@>)tjYML+KgpR;o+!XBz&n zR?uhE3rxP1HY^$?e9f%i14`lYik6DNF92Z`fkm8lON>-&0v#RC zgn1H;7rGV=-yL3Z72CQdEPXe&%-7pLu|FU6rFy(|t*$pbz$?AI8ErsaE~61>E# zB9LZeHKL_=;1LGrJ?jdDY>BSo8)_8R5q!h#krzB{6@fWU%z})}n_~sMFb@T#@Pel3 z!Rzp%)*J+O>LN`<8H6lmVBR={3{%kpN!i8hQz2|fg#tkjT+3|F+3oERguc1Gd3w3f z#BN>BGgV31m#{0@Tk}$Na-L~e6)kgTRT3BAUeDZj+=uuJU&U|wDZIR^o)BzPy>`5- zo)CmYH?Y;GVXqzUh9WUn#l)cb@w-){w3zgm80NL(zO^je6cc`&8x9X~!{J9_!nfTkhS$`pVKp1H@4Mbry>_(K+^L1E)Kc7}eP1iJ zIr00th}VvHRj(awHCJgVX1=R>?P#l+p@mG8+OqIFb)?shQKUU{A5OJ1$T@s=Ts6XH z$EbCeFrV%AL3biZuFq};Ds2b8Oxh((#97QzlfcV`>9Q+BUTQ+2)6v36$Uex9t9%Wr zU5TrV-!{kXCasl-iih539Jf>DIAOUIzD`&ug>L}bt6o`UYvDcLA+Bol{I+-!Mt2Ja zZw;s%^1Y@i+bJ9Y<-8dI=1(PG-+ zsI@XSvAgfY777bv!oW6@nF!LlvpWUoiLbZkP)r$Ivm9r=rD;{+YI) z#LfudLD~|C`vtBOE;YF;R)P>DopLEfERRC%2Ib-yclk=WJXB>nD2@icCi_b8*vej% zPZfBk2uPv)Mt++CZ)pbYElhT!Uz&ynOmGn7I=`)}%5y|mC!LxtUhmBxve^fPB3o&n zkPoef=2Y1nFcs#vEg9msrQ)G`+wQgT#E)B*1^hNY^Wd~wIG==EWFK?mU07v1E`CGA z-k=G=Ux*bgH37I}KLG452XTTfYQso9V!hS3vMNU>Cb%aA@PM7WVtG}Low6G^3vj8W z(4<%CR6Y2_ZxcwF8zWjrqHl$=#0`ZSHbp-?FSOjDg(@mIXVwgy| znLmf9w~9n|(eTZ4nI?JN+`MyNG`s*K;V&Eh^5D-tH!tR*VS`$vrc^7cQmve(R8Liv z>d8V}WhNGI*DCxdbPWXbUlIW3q91ZJ-?>|(NK!xjQgB|xdgC(k8Tp=Y#G}rL1 zGAF%mI#b&q*A-AKDspdJ!a_WXIEvoE3J97Z`I>PQZv%E;h{eUJ#TncamaD#mU1jfD z))a8oemBRQz?V_wVs@4%Rf)SxWjHr zjkBy7;S$|N=3#c2grwimnp)=Fj+dlpPT-YfR`S=W?0`q*7TsCOiSe6W9{A5`kt-J+ zAQx@szh<}ObHc^BQh0XFF>l_NQh3a|ICt`LV6J*}GvOY+!q$!s`6VBo^)UW*eoM48 z7oE>$_@-jnME{^(4#R@vtQa5rcx=wuT9~1OA(z%;s@B3Z_(+8`v}P*1upxf?+@BPH zfbylZ(8@?qmKtPf!KyUp(C`oDtHAe7`a~bdkY*kKk})HA437zQC=f3BbxrXss#V87 zYs^^l74b_u(<8T+uz%8m(ESA*C(Mz0P5hs3$}p0J8OG^#8#!)4-4u=!`NQ)ws)#Y8 zO6uMS)7_2U+cHdIT;Tbi0;lw*9PaoiE#spbGE9)mOwrRdOp98)H^9gCb*O1L{}c1q z&(GMX7viV@Vdz;Xv=Ne+&>?teqpG`8c^${87tFt?hJK4zDqWYs*L_mQd(a8V;bU|P zB&s{16ZMC?r3FXtWB2yF2w~pjEj#nMH;{e;J@6fke0XFe-LRJ9ZbbM@G!>!_(~kr9 z#|fkJf|noeIQ)iVebFuk$m@BSNGtR2A#)(+LP^ui2_xRd2Q3dLZH zN#}F~w*FI$Ux2S-d=exOU-5K^KB-qAdy1uA3*zXe4AXypbgzjz*E12b;-gzkAL*o> z0Qi!C`R6W-uxy0b}~FsU!Udjw(3=mL4;mV~aMAQ4=)j-% zXa?W;>dQ`0_$IycEw6g#`w!79|2VhLWIinX1O1t{`J?Mn0YJLBL+DRD^v>Yc-k*`; zsoLQT6>o4Due5j}j}_f#DCpD-LdjDMbA5ILud*AI!i`Y)7h@FezZMt5(N349#oWcm zgvA})Ao|viqLAUSeM}gZ; zwwufsh^`Q2zQE8J2f*6Z5Pn`fjNp*OpD+>|9(y^2|C|v1hK75X#eh_mq2X?vHd8(N zXn$Tj64@SCTnpg=r3TP6?w&r44=XT{nnxHHY@T)_;QGa->(Kj0Jdh$cl=rp_^9K>+ zJ_N-bJaQqdz0iA91)mujMsC1L1Ko&b>(o?|O5W@a=x+Ud_2MO-WU4UB=&4p9%v)Gk;5c>QC@#s!#nXKCh}z z1%#T#R$i&D{2CA z-F_T){FCj~jy7xP8-y zp1k@6z4TuLP@or6w;wLjI?}{y{*YY}Vnl?Q(K^V;S#33BC?KT;q zN^S)9T_Hi)itp`(_Xiy!pvj1{dNDg5x{%w%p}N$rqSdx+BXFb6Gr`@a^$7ahf(0kq zE$3+luYpXUh1ydWh!-Kf5Z7?RF~jh9tGh+L)_-k)1O6Ou7u9wTF8{++6T)$7Gqd4@ z@Q%Yo6aHAoczgmN;T5zvgyu-nhy5VQas3ykr-PW~8ixs|sQh@tKA7TK&I>S)LWuq^ z?0BFDO;Lv<39}5xy7iPl1+X2YdO*fDVFKjKq(!tg{=7Kon6cJw9u#isZ&e91c3W^& z_bSJX4ffQBV73ZL{c#Y8F3@oI*b+G67i$xM7Yz3TuF-bStx&JPIR8HoAYhqHtIA?$7|p{Uz%Lc(H)9bCcEF7E=CuK!+s$5Um&%2msT8yWbVtCUYJ57N)hc$ z6^Ki)B>~mV;y82^2c_gX0#=wbPjzy*+jsyu_#THkSDHLUp%9;*o9_q&?!d&-HxD8< zXnf|n+SZN2IA(a@2J!s<-BQ+*;m$d{PD{U8}=!b%-m&T1YS8a=B#)`)|8;7BZ3aXS52n5TQ#a zu{$h8Xwp&U^iFCY7YML9*3%7)wPU2K0X*?xYwsc~Nnh55Blvr9;ZCTvUnx$gv-Q%? zR2=uu`JayaihObF%U(jz4@Q9geP0;p+qi!g^ndk*6oecR7x{AYMXo?h#-k#iaeH6Vk{ z!_Mm*+JaoEupXra=nB%hcsgZ+i#MHu$)WY`#Q4-;{Nk{9ev?=GpJKQ7w%1V0U_N07 z&BOe7j}E#7$lC$v9%33A73%Q>0=b@X(sA^eUNk7iisL;h?=tA9Z5R@p58vrp={5Sm zQyt^U4imYi2)FtcpDT73c#6)78gWr$fH_$tb$c?EelTCW1L?3!oAPzaMIgXoBomtD zbq~WxL8?XG5V{l+2Hjt3q5f;cak$R;Gn-50Tf&ReCalw7LQ@(p6!bEx zJrp7Rlmhls00v7t*~uuTXayIl^4ZFb7~N;^>4eDzZ*R0^a;bNLhIPBN@+x#lco(zU zRo3%P23NKb{z!k?iS5Mlqvv>jqI(JY+lR@a^w}d=^P<+U!LOoL zx$8`R9=_0{GO^6DgN;WGdz(XPpRWndR#*n0tCE8VeC&|C?l>W022-dE`nXoP_EB_h z8wLvNZWi?XwFTl`s8ohjYfz>jLXgpd% zVEktTox_WU68)#H#BKG12o*G#*U%Su+K*L1rNg0JOOC|?K{gLCHs2Ho9#+hkw0b&? zCH6VEwc=0Ekyt-yvyV5Yxi1Fzl*Xg{?e2>ze!Bajnx8BkfU@M(=Lc?KPR_1&pZXph zhp0W28D?E^ZB@}D zfO=6d;z=800mo`R4CL1%`9e+ahdEFhdZtzgb@(5l)gB|v3+qn>0t11WK5W|G_YCz7 z@p#T8&cQi2X<8$rv~=zZWzzKX`Lx`8ZB78ao`RY<2(5uD<0?Z?Xgf=xN{jVUnWdLpiv;224@$22%yPC!=106ix-K!G50?`%Q6eB%|sb~1R zQu_Vx6Xg1{Plc>x=hVR4=s8$g*{2~2zW#TO~5PRXvFZT4B3L+8N;iVgt2X5PZ0AU z(*jWZJ$ZVVIOXYK5``AA_lGQEGh^6&j{n!$#Qs9DiTxn6QG8ypiM>uXh3(Ejh0I&c zj6542Ss~lbb{&n~aGg-(B7y_R2_u*h^rR z>%B+q$<~iy4m+OAVc+hA{xLmh4m;kk4`;}bBg`0f{20ct&r5$A9v;&e_K(7hVK0p^ zhD}SKjf^Gwm~%K}iT}ST?Eg!qu;X3!Trz}xUN(gN#?Y{A0^9r~HrZHKuv0omSiw$d zCM(!@+Wiw>kJ{a#8;{;l?r^uOJ+=DYsA1U(_SvdHfLXzw41+jsZirqK$mnDv<*0v~ zSUZ?oV3l5eOfhx6Vr)~_hXd|+1O8MH_s^^OiFo;e#JytWIyo2;G;n<#8o1`i4Sec8 z#todHQ45k}>pda+(`^3^W??I5>BTj(jN-ak@dK?mFXgR?2M1d4EDC?M;Z^NbcdCWu zfc%{qZojJLw*D54(CgJ+H9G*JbIB0=+4_;Fy~?Z>j=BBXnt1O^f#Km{cZ+tQYi8T; z>);=M1P0XmTm2d+Z)&r&6|M=NDN|F69_BWjn6|txz+H(_HSIH+@aoE{5n^2s;v1(% zKwQ2YXpC-jLA6#1hWZPrTJz&_r1LQ17G6nF|9MI-Y7Z7QIapMVQj~)ghSP904#igj zbe#g!ia?vcSAY)ngeL2Fq8^}%DhU6x>PjK2Pa$MwT7~X7I@2AQp5+Pf+Ril}$iQU* zPm`5gl;{c_QSE8VktTHJV0i^Sn$T?~D5iJ}<*Aj(Pc%J~=VWJ@vf4-UGU>))d46~v zCvRGsybZD$VxKZ`!^Dyyq}7tG=7~5Rm_QD0bdp_*^@}h}eAX!H>MtO|d`ymH(K37k z)r+N6_8zECmgXz3w@EXV*F{o-@;XuC^c0m)GhMyk?mB5-%IbPv@iw5b}&nQ9-kyxmDUT zOotlBTcv0!%MWw$AkE{fbR5hbhXDavu?ej^>m5BQN7oMYn=N|hZH7PLv`@G?%O5Y@ zP3I(eQ@->Djs@^KIp3iREbMv+C;iKBpkoK!{y6ESk8v`qr%BlQqA&q84+M{cQ*x}N z96~}q;|Z&D;siU(Qw{m)EKk|Xhcmdc7x53i0ZBE~PR7=<_c8E2{DW_ftB9l&a~1HMaSIld5y|@o48a&xUUaj{yZu=$gfx5@ynqlKFaj&aFoaTM%AVA@C zbhRxox!RP$c0fuQ!tTKW;SR^RvfF;2!R1Q%gY4{9Gd~l;etj6jGJ;`-{QPhTGsL_h zcSpdh-+P?q`0jSCxmIszbkKG?Tm8%%65E0aP%r_V)*^s5|0jyb&dv8uYfRn>=ug_i zR*2k(0&FqKeQNr#gXRxtbtng>xNvO-j*?Ls2qAAECMBW=K`O&=~7_w!fL zx|HK$aCstX|7 zLNG|J-$y_&iibu}SFe1V7%3E|2h#4L{}aum=ct+1L^hMOOaVS|WUc(;F&iGCDx?Y$ zOBOh07QT&a|IG_8sWyaGWQ95TZ()CN-^KAp_gw*A7r32u8uY}_rmY{wOhV6kT&i^# z%U(iN@Re_d*})E-)c1j+U~1NTgdX$4(@xM73ZKSRUQzoJ^p|_YH2of*6!*M}pF+Wb z+g_5Ac49V=tD1o2w367}Y_hV$lEHM}T!eJ=<(~Btm*xMpqFqj+3C!t{=S2Xc-+mC( zEom**yHhWYlM$FrZ_)5JHBh6K_9FECZv()-8^)zHd$P2iXit_dxR&cayWDHp4>)Oo zuy%Lt7o4F1r>ePP(b>imY^Nk&Oz~!FHEkZt(0;sKWbWIc7W?w0YhgzDbo2I1uF%}K z6GC7#gvP>?7^2R7A;2$nUl``^-o0kJw!cNi-`?M%6Jm1$ljpLdXfwrj@vyt+Zr_FI z2f7wPCQZDz8HE<-$w=6}bh@@FV=ja)qfONz^RVzA^8QaQ4!dEQ`xa?#qnz%Y2?1UY ze~0<#x>a-Q{DRotmNmB?FA*AXYBUc1IL`Z{9pC!Zj{dbM8BoL>cYhb)6G2&;lN-ct zn&H)E&-w&Bua2O(C*feEU>%i za{%?!`-}_nQ~Je6o3|1g6RgsG0Mh+i{N@UYyK&FYc`|a7W4LE)vND3#_9`Rz#gBp` zI5)-!e(wFzBRHNA5u;@#`f4#-_cX3*H`e34Mi?jUuE2HS&{-=ZrJrEdH(nw`VMGQ6 z`hkbWOuT&krJ!FT!_^})z@#j{;H|(o=xB>Xt5$6BN%1q=%x!{M{90_UKSQTL+XPLT zKWRS)L%#XQu1Pzz<_oSk^95nNxlf34XxI9UGrM9B@>+-5|BXZA|B~%}1P5co+ePvD zVzpL$w*U(HU}m0CY@cb-vootw_J4pLp)>4jMjy;u?Jm3vxQSVWcvmw12U&z*rXW6G zIbvIcH0Y=C$@~n%ivs|4X06_LPAj(06pV;4ROJI{;P=2b9e!gwRiOnK)W$!mq{o5lf(Vk|q^+aW~Vklp9k zL(*Z~jnTA?yVd)U3ir*R0cMJpq`GbKoDe5=NWUt=HK-(Xgp{Ti>&U7hx0`EH12_Wa zvGi;m?>2LWN8iTtU&%OAeU0M`&`ZeY(pfQY(6H~QC+{?#Xz6L}-)R+dwKFv{kBHf) zS)&^EbxN7f({i|7<0DstsWlpnX}RErI{Mt+^*k zYXxd*pcaz%cX*m4rN91f$OPVH>ck?LgZEAC-xjk=+jzpM{m(>y%)kl9Jog9Oz$b-G z_o>7|sAZF`vET66&vEBN0{ZXLnvXl@J2*THcmMB*4h~N36SMouFgicCN#)(HlKQAH z@oRH!pAg&FzoT$hl>56<=Nt#;D21FiC^;RaYF~cSxMjK0TO)GJ!5lG86QqfO9AExc z$q}REc#LuY1=PozrnfCqK`Y)L=9^9LwVbEk!Wi4TQ)Put?obslpGmJ)0r5NW7KqrD znr%c8WK3yx*Pfqec)U5WBc&Ot{lDM^dWaXjU(8)?MOPy^DV;2hPfnpZtnpc;%1Ug? zX?SiMI*flB%LQa&dR9JmD`^}}WK5w8S$L%cO?Wg0cB#b{ck}pEi+<}3V)m?2U3v9H zxhtcSvU zopX0*shXm*M9VDdI5E(1HvI@Y&aB8@6K#0aVsiJXH0_!`cSj%(f!X!R9~@)G!X^I`Ns z<2F;n`Rwi5^&AxlNUvrtyI;2F-1AsH~LW0BxvDg9O2LcdW&BpN8$8JrvgTs&p!H z`%MGKu^>TfZm$KIQou@jm;enGq&bqUQv0{qAk)wmMfj1QU?ZGW8sY57Mp#&Zj+gW) zMn6I-x9WuK*?YAB}wSnqpBs0B2fA0YQF zcMlHjNCZ^pq*?}RW5|S%aG@z{m^$NGhl*M!S1PBYX9;pAJ?l`(os`9HA~R^FFuOv# zam7B~@Tw0_NOiel0U?LJjpNpKwl;JE)9`U^s(I;cDh|&VsP3U@eSzWC+60)}58WVF6Edg8zMYZ9K<+k#ZunC}C9OYoajC zJ)F)@bPrqjIQMWCuWKqAat|*Pv~89lm6$zC^tpR#LEiPeoG<^f?aNBa1CItA5Bmi`$eV{9wvml~M0sNJVbvG1;0a?^X!maxFa7VWJo$S)-3102dxCga{pP*yhx}DR|iQmoyvE6@r>g%9wal#5ausqDQ z0<;aAH&inN+Q@*b&enn)DV+@qj+@tdvJ&u~Ak`30aO3iL(^XJFN;|GSa{fdwo+cbx zU0}^|sJws+Hl_6MlW1thmA=&4A>mbqQA)G_Rb{R4A>nE0KzPwNwbob@hZMC+oqep_ z8TaQGz(1IV7GUYpGck;7TYFuhTMl!**~ekJCJl_k{h#C3@MRZfd*whpBoX_h>+pb8 zTWIu<`v~&}BNiEPx{rjYyPFdx%gyyY*;KZBbiHWpK@{StS;mQnq=wZ1ElV#h*TGm) ze*xP>n3-R8V0$K)OI%S^OL{0k@!-7(JfQiXWS=#457B-S9ZNJc{1~knygJVAQTaiI zH;9})HGX;R8iK8LUr_CwY3|-JJ*7F-qBHD82Wz^V0>^l~VUc5zPbdo$Ahmg|);XbW zmkPa)Lf$m3--1+wc{-j&@7+O*s39N@l?)|Tfx>s;-ZZFAp2=7X&*Tm~Qz);y(lTmF zv%6E38uuQA!`K>>f9^Eh@OE4`11{9z(y8Pj)6n{&RxwYHw}{0UJld~T2j^VIkaseW zo7Q5e6Bz-~>J9)Q%I6nIf3*byZ`GA#aIu?oAS8ySKvT0!AC${ZQyJ@i+W?BGqvC7bZ9iu>c$R;mU%`cED z6{s@`q)j2H1^9X!K||OGT=P}k&beG}?Fv8^^^_>KDR+^4xIw!Mb5r_Lg}$9r4R0-7 zcpmrZ?_ir1Sdu==EcEU8I}*nqPbQ!Iaje?EWoNk8qs>4x8TiKg!^#dGP}A+uskUDQ zv|f#Ar(CDdS-m@3*Q5)mVL+9M>`+Syc1o?>liW(#2er+j!bE z5Ovm>$$3}lMb)0)H*0~_bM`+1qgvm1l^eTO8E51;srnr5-+0DJ-<-i!chS(;DNOXP ziubJ7c~)sX6QI|_karEdi{m`e?O8g^1hi(EFbK+eIP&)Q0X%qE!67qhNBU15Q`E3 z2f)N5Jmo1m;K_SK#>bQWlDJfr;IaH#w8Z0b>WhKN>ws`|52N$M$Axj?yVAUD=x3RF zyxckrvNfgo1yA;#7hVu8PobTW_sy0U4es%{RbKm2$_Y=={@v;(%Q4aNhNq~x-GU|& zIVOt@jY(v)xsjGs-*q?7*R*>q`|&>Pxb!slg{U1ce|a(Sq9^-35W+usma4Gd@G56` ztFF$G$)$NL9can$WplgXr|8i^{7!_@G)+sFNXs(K7j}?!x)Y84J14;qENOhJt zMUSOfJ(l;pmM4I)%~sl@$9r*uFi6D%4*vTLuc{4iH6eoOp6r*+hYdgdFESW*00Gsx zFK9hQ4c_cJPhJNe&COxZIvBJY!l1ROh=ii$eU0Vi<-I?}>gyX>BfY%4G<9D80WE+1 z?xm^o>rQ_M;F~NxNa<5e5aqS>IMh}@%ykbKUX8(N4h&h-me=0m%>W&|gPLdw!wSlE zsDU!ONkcMH(f86sr^A0e#5+{02{lJpJoyZ@yq}w!=7{oG-e+U_dzdUYAUrPJ6H7V; z6oljRFfP@5v!BhevUI-=OkPe4*oFo=zLup+1kTBOU(*7O4;>#0PPII1c%%)-`PySK z!eU3<+FZNG^1RpbtQA$4=Odx)#jacXtjh2RUY-bK=>h-*{?B_K0Gjh&bnOze_t4cH zm=(?fyhY(XG5ZaXt>tU-UfQkKXpUtztz0nQ{OOLri-JzIQ|rG65)X(1(EkmY?7Ov- zG>4j&UNb)vl1cs#Ma#>(W13bjnvY2s7uT&^G*1gmng;do>zJhRM57crA0|b_?7o!$ zmVSH)2aV7bh#ZpVDa{4qFw7fqw6z$qLkyV*U3VMyP0y81MnitIe>2yerB%DXixDO} z;_lCt-ciDhP;;n1-ZZWqa=shF08cL8AJVGWxbcQS_uu#p;;{S7-M&Ed1D}9cdXwt0 zBVjIAn+5VZs7&3lf|M!ue$I0hXWD5PemUnx$aYU`v#*Q=v;)$JTgJ^3!qak)!`{<%@I%IpdU4)7d(5M`k1!}M5BGm6gnGRH;#6GAeTBb$Z-Dif!uITv@ z742gc{qdL;?S3~@(FCmM9q)}%QDdm0`EoUB>1UW#G~UyQg=h|<_XO62FNhb-M+|PX z6ehlMo@!vd0C)_$ZZPaK04KXI#0cXZaX+$qjP7QrG8)P9W)GTu&J%d_zbp^BmDh`P z&+&ROYohPSpjGr)Ar40w=?%2SEa*@mHA;TkN6JY#L3cJG<5?tEE665l09-_T1yKyr z=|;_ym_($k=Ht$H#r8yt5xTeeO~lslNERMh4R$o7SR13*?+zlx9Pwqw<#%oopLEmr!?d`wUp(x)5beqCVfs+lB2V7OqLxHm|576;8?P5gzqub3=ywC2qSK^A z^(bwX{c-CPdl zE)fk!BcJ#c>Q=+i4-78y2xsL{-BT;=UOMikCQD$kn%6E?fy&Y{&;^?3b7J1vl>VR+ zHg2wozbeeA7x5Dt2C6L(0&k31|{D@8^p7&b1 zU^Z=)7SJk$UhM0|7-okj9aV=e5q03wQ z@pI2T2kZb5Wn%5eDg6vlT;_5_tu+q{S7RHrXELUMrh#VZ-40EkTOcjjheYaq08e^^ zblWtT=~@Qs7G29vle11!vbs#YcR}Jr%V|7p9k%Q!z40J6KO0oMxN~8ZKBb$!xC#-C z^RdoWnvbysLWc(aF9Wd6WyiZRxWMG4c#Ug+iE?=@A5Ud0#BVk%(LtsgmF10SVXAZ# zISQvyqf)XPKXDl3zA!ArN+K?6&^OGqF9d?O-tt+$2m?^26qOVz>IP{u7L`RsjhUVm zI1=sBG0~oiu<-W9sFVcdX$+x>EY6k3!4zmEWM#mKeD1UHc3f>}Kua4?oZg##R;or{ zaM^ut;W{Rgx_-Nk7EW&R=GWQHEv`Jnt6lxtyam1tPQ~lR`p0Q)L~HbG46imD_BH#H z?B+Jt^_NOD0aE$LyI*5kgpN!JHv4CyS~o<>oz9vc_)m5{qI-fLM<}N%XBYhGe3&-QhJpMnr zapZN^4ym9ugGMHFuf6yQ$C-Uwm+;$POF2NV*YTBZ>1Muso*KUSP1se7@XuPer13AC zw-7^D&MTP+56irXXrp)DYyZihOyX1K{Q+M2B+P&U#DCSi|H6Q&7%*kt!s+mC!1ox@ zqMyABFf3{fUVA0i@CEf3rSBn?zWyRM39nsn!ms|b3L0)Tv6NUltFX}U>bnEohF8Ct z+BtA!=HY=)ruGkfFjFu!oZ!bnKi0VCa5?$mC!DOS1C!swbE7y}cNoi(akTo;VVrKK z3OZpXAQN^uO&)CC=x5URU_fl~r+S1Qk8qk6Mh1szUM{cgEv5^?c!|76yy&S7juY3i zsr84WM~c&;(1ST292^df6$y9-vp|@Ox86X9!GB>WNw**}9f(hCS1($b%|!?+a&bXB zTbB|LkEd?e)oMK3b;#omRY5_nG>m&N2SFI)E)(DhR3e z^UcCUde!|R5&G5CgT2@@#8&s;NYiY54nrb5&^jJg-lf9UPO@^VIy+M6e<3@{)^=AQ*dpg*dQ8bvx8BMBEKp42t*L z@zB&DPOzZ|9MEfyPdezoNygSEsh?K~lOr1qh!fl8UxgF)Y~UnGY>BlD@ppnYzXy+l zLD@M#vLRZox|!|Z`Avwca8-dros%m)(1g?yk3(L~N0U$~6pyUGe?xHFYZh&L>7>7+ zt?w?6rAJ=oXkL_ZqA(K`5Iv0uDFILRX^%zn)SgE|HCfKy*(&H9>h&IB(67Pua*K{u zYtg4rEUs7N8GF|wxpj%lwYc!&`-Rxy$pe+8?-!a5w5OBZH>m~C0u{7w$CSCaxw#`= zEIOZ?Z_SZj{xKf@_@q}&Gu>&xS`KwVLB6DVfM(fdi(a_boM#lWh_KM-`@~gRjSq7E zQZ1Xn)nxYpnI`280cE2aS`7`);b|wt8`KH4pmC`0*StIF!``3JSUs&k*(~~Z2gl89 z!KwVgfVl-GP%XW9k|4EqXxA-d;Q$cc5tVA`+iD87Ty#S#KQZ{FYiJd;;)nhi3Q70{ zDcK*R-vhl@8C|vflt^4a{p_uv3wqE6&x=KaxvO)eKRil$1T=Aicv0+wZZG{3f)d-N zeyFiP-Hc(=vuU{Qx2`!twYS7$hw?O@|1uDezP^ujEAcp4?o=8H?>`d9=NCA*U(kZW z?O5z9m`Qxs(>RFFZ#;V?c)CxKqIO}CgOl~N0KaG!{7ku$tsIv**dp!$oG)O$7MPrT ziN1JF!(a27RFk3cH1^b`sk4{{hI5^>U!sc;TziDc4!#HYRG5fXu!(5HpE(5`JkV*R zvjT%A*`}H_DvSlkxhr3OMUfImAP{w!7$Aor< za$*NCxQ6OHX)o5*A4MW|1FkgVoFezF;4Eyn#kd$WMbK-fku|)Oqker|YK#!=Zf4N4 z^}?4$txBB1?hZwwnK6hyYk0OrrBQ|$d7lQ_Pg-?uSl+17Nxc_PucPf5@uIW?VnA-U z;SRrh$#JxxD8#f!(MuX0x(hqD=F#Exq2*foi|_8`#sGcbT_bkQ$g_MJMl|bxtfR$V zD~>)NcMOLaCHZz$*gYqexJZ0Zd+GJwxP1AJ0*pHT3G(L3Kex6^SEHU;6@E2JS=&v( zofMk2%~FL;kNkXN|F(POW!}t7FZ0e-mU++8g(-dTuFzSu&V>uUY_XRHx=w3OP7W57 z<5^DAy&SnT>FzI;sZaQM8XZjQR*2y-iJrjJ5@ch-Y@^}*W3R0uzF|Ppah)P(C`S2`!%Q3d(tS(q2~KSQ)uy|`?Uz!JLATCc>0TQCIf9D)5&7#i_RBKmDzH`hk-yq~L z5ATLyaD}!>Wq7>VUxo)xrn)Z<^HX4KI4B>oZeFBe>XJWdKkg>>r@^eg6^H!Q(xFXk zJjV5F;ZNb%;|>;-%I2A?rIoDEq8hyKbRE>Tqha5n znLaWu7t~#oyxH&5pjY_$FjEb3a>N5{0%Mi-eof~baOp(4=?(d$!uvE7KKOZPD3t1L zVZfmDmB053Qh_BLaqY2U#;GHb3B zKbv%Mm|&3aH3-x+rdn+@&J@4bhHDy`Cgbx~tM z;L`T!lP3XG8{U$;1EvrK`H~KO$_wZ7*(A`R%9VaQi|(@msURf5igTsFS^~=H1`(Uy|&}Dr!kcB-_oC784mf*i>N=EBMj_}$7)Kc&*NTn zd!cumYKIy+LeVUsM=Y6K1B!TxX5k@-xyV>R5zG-HiE5Ti5^_gZtwNFn>0xL<8_Mw9j&XbB=qo*!k|({ z|3O;vUVt&cXu@=i$Af>;GpOPo$HPA-_(=uQul@|pi%YzC0m^?OB~TzWQII;NKjlQe z^e}#itzO&NU~?b%bFjJHXN|<-ebOy=;)2v%Izu|fRV%A>k`9l}s;eDBPRi~JuHr;k z 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%0Aq*3%d@g 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Ⓢ3Vf_K2)lP~RB^{iVEbY(aNL3Nn5CO$GH~ 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

VJi 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⋘*sm^1{ows3td;=+3qdGg= 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

#|<8z!3UgxjWZ%JS|%KBWVct(c4~Lv71?2kS+Cn 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

@_dkk3d(^p1LQF4?gyy)I|nV~*u zS#7I0nAJual&c`8+a1O8htcddL`+93_y#CQE63jH(xt|E>+hQ9BkS)m*VE|&wr!rK zEykaJK6Wu)hzzc|A(qSVq~LPAg;F;?!nl`27#GboHO9TdGg-MCa-CpfV4aH@1Ff!PLF2z>G0wiMZn|gYIyXX< zx26>_qmApMU@svpu*^m`!*zFMtD}7F93Bk?2^5Lh1}p}39IdNtFo@;iZnP#ALoCEm zyO8YdViBw`nwPf~_)ueIjrfd}-+F=797OL&Q6_#t#e1EU6n8@hk~L9lnqH`dF>{A3 zcDRZdQ=fpDb>1@?GxsAi{|cE|E<1r{ChdGJdY8`L$;?2mhe4LLhndl28~Df2OLo1X z&b&Z#D9#L({X$Yc%i%b);R9gy=TXUg&}V(TO{0IM0cUtVu<(N!ps!AtG5_bPWz=?u9xztTCu0KVzRTB zl}KY(V1)RU!E{5+xZ=I4K3;#r&Lb!(vEuS!Fm9{K6Ibdh3l+HwVKyy&M9cJvq7X28OT0d1A}I014(ym^u#fkNf}!c|f@jpeO^`;t6p+zuUk<8~qQi zMcTA3aI|f90NL~59Sg6)Qbihkl=)r@q120z<z}jX5W*(9S$3()J?l%u{gd=baN>bi&{D^G+c*=B!g#SQI_N{2S8-X5qwE;u`1x zkj_+>M|Cs9t@Zz8R3Tx-NCw>WgViHyX?0Z}Dnd@G>qw;8ckhYlsbE@BlsKCI=`p_g zK_<+f~SR@U_Uy#3!&oRjNw|QK;JKb-&e>58jIbTkQT|h#SN0AEE^i9{jr1 zHxMbhc5Ib=?OV+9#YKPldL(9yaFxoOF{25U${L}UovxK-4quzdfNWAs(h=27_r8v+ zie_aqx?EVIRY#}1sjAV2-RUv9=yjttw47f9rJ#x_FRmRoPYSmSQ` zdk==A>2qtt3s2}dRvSi2f|N8Dp0TQv?nV<1onfzZF@_B>hV6-A*p4W}e$R!p7~_h4 zqUMU-%AAmjQ&9mk(ta`Gj6L|fSZC~@_TGpQuC^Ibv`<0Bz1Q0+j?+r{Mdp;f#on*z zro~Rniy4C16Q@jh(Sk~1PFdrNW_D=`9h$^>Ol!&-`CWdg+o&Cn6p<5!g;GyQ+HWo< z_nGDUGtD@(>c$K$H`$os%S*$cW*aSZhqinNCb?X?*FWNdH}GeJcUE6s2IO%0oYgKm z9?x+%X29YH3t({qus$6O}Jqx?w|=%9RcyX(5;J@v9Nsg{#*b1+r^?(9B!iS)@xaFYSOHyiod9~c;lHoo@1G*8JT}8mb5)Yv z|6yxR5WRSR*omcc#gt|zx@6{>oT@=d#ISG+{B=&4Kt9=P!l|d5ms)JF8vA5H54^bd zT%B{oakT1Ys6Fcdsp`f!?F3*$8N z_G{{$>OzBJg0EV?Vymti;e=_`Rp&S{zTeqx5O`Q2L&Z{Y55fnK&JYGuFR-4x1YlVe zjn0W@ym4+_f1^zp4;0T3l06x8yq5eW-XtpLpNL+d@NgHpf1+`0$fU?OFofQr50{2D;4yZT4J?)NQ*q*MhsR@g~2XVu_?2rFx1vk`kaM zJ;{hk@sJpxaV%+8Bc#w$WUMyyQGHHn?VLgUbqo3#~vTi%FTmc8jI%5%kk-P$uX<&m}i1A$A-!j8IGr zN(SQsR(vLV_pvfn&;O~^Vpfv%9}4=cHsA(YL2BSsoZV7$s#`U1*IF1_8B$dSC(K0v zcW0aeJ*E>6emmFL*xS3YSXsJs_uZ#3)zrDhr7T9cxfxRT$B1NZ29!C8Wll!R6kr*W zdv_!YLOE$VxyD7SP0XpU3%!<=sFUX6WK&@2&$ZYDlXUqAJXrAIEIgR;VE`VC`0yn> z81Uhr#PFti21lj|y*6Ekr$ICEBLzOhIW^;u#!3c>pRK}>PeBUH@rn(958!Vgyz<42 z$;1$#MyWdiVO$cqCoWE%F&hgp)?&e}wx+xFcQGNC$t2KW1?FWAtW2hTWfSefPnWd7N-XjugIh%V|7y zuf%yk73a_jqvoUdH!!oDbizDig%N0ecgsn<2i^k%_#YszF_gA56)%UPi+5m=bUY4Z zp&2tGkYo(kn2Dxd&S7f9xM$^Kq6-a8nF9T6)qMj;Pirp|8I_Tza|ejL2XRn#6(;afSf67^q7?vv{3d~Dnoe2{vFWurKQ6^Th1A_ ztVP8pN4P~=dWUy84C-98xZwX>Y|&5DmBp~6AzaNO5JG+-xn|L&_-(TL8}-0D(ri{x zy4Ug!7E_v~?ssIT1)ncChG+CRSvB1C5&w>JIBIkHWM>ugE8?^a9IcpDFg(j?tzeW1 zcm5z8lM|(1uN6gfQrEm3n7X^lhMz=vY60Ds3nXKwf-fQ{JPAm}RKzIE2ucDKvl=iOvK_ilV}&B-F%(B!8h(t!4^1BgUC`(; zLk}@?El>w{x7M6u%mjUR8S58d*LdcPmmlby)+(La?yIS0qmwcI2SKJ;JZ#locKdPx zDn8eovf$#V1}9;tCrO=DiN@Z&PoX__V`TqY>a~Hw+1UzMqSA-a6}i^N3Vy=!Of!5g zT@U1d?@86{`;PEeysf)}-`F;{f`8W!DefV7IAog5?l}1kvqzw|ulUj>-aY(sEI5}v znEVy|oQ2-DiOab+zXTd(9hHK%|225VT|L{Nse>Q;CSkCaD zK-mc?El~egyzQTm`2OWkWiw(k8?igT>U3}SwB`6Pmpx$BYZiJ#(-9r_>}m#QO$bUK z0(i#&-m&QbuMv@r(`vcv6t=vL!8YiyO`Hajo5NtIvIm6xu5CU(-0?1yqX2mbpmsox z0Q~(O{!T!88+`u@(wpGQ!ifCtdzg3}@_zz(o8cK? z-t|=lpQ_GA23v|Byfg(ySItNGJTG(;ti2lEd?p7d+ct0g5{^r5s~OU0`xgshe%B`m z66%<()p18B{9#%{1z*zs=G{=Tg5Tc`726aY=|1?w27Ca2UqJUw%E)0&KlK$quMxga zLt4swmbRw1f?xm17x3`Ko7;e7S#5FZDFYxe55J@#j{nK4fUT)T>fR=us;&uCLng@G z4b=gtHJ9GakScv2s*F6V@Z4zfYyUx2zsku$I?uW88SVWMApTLP_5f5{`aZNmeY5%| zY$~SiAA**wVeR#^>_2RUTz6zz;jT)Leh zv$X}<4OrfB5KuXq0ST=*5<=T}2~Aa!15%jdYx_(bBb_e}cyQo@ng$#=P~gl$;NxR^ z&tSB7{)e3iBDI1a0$5*mRq$WH_Zfh_2{`-x26CY0BNd9(3~4;`oPpEE%ToN{y=a3H z#SX}`w=W9yjuZ{qjYk)1&?j6PVhN+r8Kk`i)dW3@?kLQFuWds)4B}*jdTFfe2wn~| zE&SNR#L7MRxpfE!Gd4d=T4v*B^-@NJM>YUf{9_*)wDv#t4|oWcM??hlw0}=1kMXPD zbiRm!{@4!yKLW_d5pp*`5LS;0t~=uNR>cUg|Ap+@cB7dK-i2qIZFt#N-3H*rXEDG? z4en-DV{O+4PNmEWyopUoZUb7v2-_ISKpBPzGCG^T;>!=biFZX{j%$Vj%5&p zSuvt&HmfGg2?;0TkQW+H#!>vbNEW_!x*^h(3*VWvcQ8_Vpj-C8`2`>rWC5iab)esX za2wmP1KTw^CpWOpXQiu07$>E4XylBub+dkNeG}aP1t)7@7>8l2854v#F(bkfAE9In z-Ne_T&vW+tj%ezn=M(({o#=rWau!278cZ5=)|#5rSUmYC@@eg_H3S71jqmO^>7p7$ z(uOIF4r9_bnjr0DTsM*r$j}aCXkcVeN)h2vTv_W$OL7CY6dCBvsd_c@G2@$;vJQ`o zoz2rQ;1#zy739%a83YZKded2&O$M@%;~16sj!cWxkOp67=`)L*nn_JED4UQ)+MnJC zm291}6o)l0Eit0s+%}NkH31F=e9dR5f=C}#m%M|`Q7(t&-LT64i-uL0w6F5Fb4qU58Hu_fSt}9Ku|bEpxDQ00+8v4cRld#BWP|XP6?3Sf{F>?H(*+) zD+>)cp-3a#wpHa=6Z{>46jU!l8oYP?8e_eg#zmBaTJ+re<|UC4W5iAB^EskihtmA- zAvK+gg|z3~MIa$-TDqgttY4?6JHW~XF%k@e2%ITQ!pvb!Qpa&nD0uHVHk{cHEFHXq zC9FRMy;1tf=(xG4dh^H(SSK&V#|;@Ztr{!+5H-ipyo;g9q{6$$O`6_R^I3>71IQ+O zLsU9>=fQ9a-U=Vvn^?%pk)+SXTRK}iPb=xZ=QELw^{>eL1)q0*_F*eU$EUDd2=sfu z1E~hVjBAJfh8)q@c`oU5!PwayJYvKvjgT+dFQhK`90L;@`~Ly3Pbtp?6?hqZ31q;@ zj*}f-^D=-QS_Q_&&d(uF&yy$j-3>pV)GEjo+dEG;>N&8$RZyTYsZ}g$?Ce#HSOk9j z8S(_t#}o_XfRG_^T;~^3*dWGhSVdrHRpN`!;YF~sTlv??j^HvQ77I=Uyw;qLfY8^WJbu`De#>^i%i+=5>UUsrXKTl)XjAL`f>LbSLPe=ZGlVYy;+f5TI z4p2B)^3ww|Q@ z{|Ph7BgeN!2F1iDe0wr!f4A)Hj&6|2&ORkE zntT$2AL6YO^p@(RazU@pY4H_wy9!s+r4rk1I_V5o)Kpov83j}44=iKN)ehmEF# z4~!3PWAllWVMtKkIT_b;UdH2*j2I(s({E3t^kK$d==)gFEu!6@;}+42+r?X=Ne@2) zLey!&tmQ0rG#Y(<0epPDQ3X!KD1%aX@vn6HDw1Vd$IYQ zr}p5+V0J;O=s*uZqS#40j%r6-{K?Lf9oq3$5LIJL$4Ya78MW(gICz6c(bB^iDSzE) z`Q($GEW@8JDEnJhRtNnv_9MH%DqfA7TD#NAfs~y=`i71O%QGTe4kd zfZ)edmOlIFjXSHygbo^g1sG{myGEvJcgPY)0}cX0&mr{Zu-@mey63d2J##8;NS5-Y zc+E<#>X=b=h(&c^H{FuAl?analgC(^6%fkc&jNqNSphMVzWp=2yAB1sD#mhEgY3e_ z4Q`5-X)HYW1WMe|DkboQyg>C24-zxmYpZ0d)v6$ zF2Uk4(FY1x2OZ~{oS$%7V6hX#A{TVSdut&*_$gjzZ-L)(!EtHOz(3JkSM^D)Dn8(C z)!YP0BNnJ;BpJiWn#C*03aFq>`konIh`IC+KZR!GSY+oR`XoFy=U5(RHcj35dggs` zwb%=$g!?Adv4=MFq2C}(L}PsD3PlTJMv70!#t5IFpJPB^0|Ic+d;qGh08c=$zqhza zSvNVT2bDQis$9(siaY9o&IQK-`+7jSOGt(Wx7@}o*1H?sT5c!^1;(~wGI6%aE6i9! zGVS>Z-qk%MV?2iQH`sL|3J4RvZx2RHL}J{eD2sxRo5H50?O_F7$j;Zk5Yzc7G7BQQEsIvM*yT<*IIC0%h9mD;TbT0dUn#g)Y3FM_;YRE2jWzDjxBn*9z56C!+O)7IR7z zl!>6*6k_t93=u1rOQl9L!V`C*K?5j5on#>EpMkT+JiA|+PD09!WHO6m(N}L=hJO5r zGn`f2mt$&>nqXLm9jC<)JqA}1Jy@niw?aGm$u64sQxqbHRlJn2AB|<2lm~t0-;%N( zRK(ik;$g>GVHIi0YR2_X-(o_BUAMM;)KlRTKaLE@LQ-Jy6-N9MqQ}#$ z1Ee{3piM3qcC>7Y_gL4gYi%rY`_}#c$om(-sEX@x9KV}vk|hajkN`nZRslf?F%VF2 zK@#!+lt3UP0SS*yvLU-B*|>XeLX=0+O*G4OEp4H-t+raVA6j43s^B{h8y;E&vSoDjK>{%)tgJLvBN^!GtR@L>d!%@BOzJM!*h-ZAtP4>RhHcp{Dnpl()@G<@4a zUBdzK`nvN;QpBv4VWNql>VF!F7^-k79(A|shJLd~iTI~89uYOyOvX8MK6mNp!{Rs0D ztN=#x%zhQyF@EqOOEF0%j5P~tTPCxEe)$FRdHE(^jQ>VuGG+rAV6M?iUH+St1bkkn zUX)lMf2Dl749#pAyjg~PAjP!h{Kx^!Z>6z9dse*Q8v=iIDb1cOvFp@380D`D)3odK-Os9jQD60XdR^LQ7=s<~UE5!FOcJEiL}AdOsb~kASi3 zo0)6Zd&kbp1!V6Ys>(Y^S?i1QM0#WQdi4*WowO`NN;lqryNy_Awc3O=owr}$A6nI) zO$;4(ut@ZFedqU=;j1JNs3V_aU*Hc{rT!C(8QJrg(Mk8?XCSxW@xF^ZM?B^6o}_Tc zcetydV6yU7FR|R0jPu@QUKcT7{4tmk8qY9xVuvq3zZUxhFE~eKa(Ie*o%gfp0h zXPJ!3{8+a#lwHuKM}6p#v8PcZc_e{(@yv@OPt`a;-+(SX_yMdtChi5~aOvUh?UOFs z7lDsDI>2lI&BmAL?)@$_T945q-Mts_d&F~r6i`_QT3(_G={#mZ8PYAK+nb({pQB8l zM_*idTzZeo;JrK6^VhLqI2Ao1A9~`6ZS{tGGlZs$n1rXj>gjH;hFClW*{C4@_-pk3ktOUxpoVj#1ii*gA2l^Z zAS0xj+nwjOHGTn^yjzn>@JY@ns(`}bhGbCuzA&^#5QFS4A0JY{%LL(SLu9GSTC zE*K-fY233T8*9Q-cd>PbxNSRzB6Qz~?$$7jFkuARnho3bPFmA;Y-FnY3!SvjS7iU= zO8Y&xPkG-)rsv84rrb^z zkm~M^6{HBIbUj`*7l$L!*pj>ihHiRX{YJW|UmW7ETByuy#G#vcaUUBGx5atV7Q)}) z>oI0~)8oj-%D46iZ+bktzyBP4&v-5e&*?I=cQBn|r7)J#<7MGdeIwo?t^A__5ZDKT zi642!`v_6C%}i47BizjI;YzJ)8TJT<@0H zC!TnsGn=EUd=a;h%=2a?!2F(iGn060_3q6|7y}|T*KPg+%6uVS&org+#usK8 zUpQbsUo+gBKW56?;qRx_MeFC|(dZs`a~K+6c+Z=4`HOgsU7f*@N(pE;?VsoUxL0z| zShBLFU(i_$becUEWwWsnKQF=IfiLibFnW46VRT34nPgAqNpD>;kj9gF1}c&Fi{@=< zAkf5V#utWz$TJ_V8RuO)Cj5i?3&`89%eLK{0J#m}^Nla0>C(-It3L%1ST|dc_RE=P z7Py-Y0LbmvJD-);bmT&t_hlJyOek~DvketOREAoY#M&dh+b009b)|BccX?Q;N9SD> z=KfMAeVMPA@CKmBeC2s;Uh^tVs0VwWChBUK$p;S>%egVtEmO}Irz=u)%hSw?hIp}-Ibd6N&lX5 z=*W3*{H^ZRFi4k9ul3FkTc;hZ1T8X_+Xyu_;C)E2Ec!E=SUxeO9aq;!dUNt~bMYgr zwSOkzPHP}3Cf`3Y9?Ai>%b~L#{}tI~%x&~b!HqtBm=xx=1$^ zD38|RePFrDZ^4&8Qj5rKwBl3Ar*0LDP%6_^5s2(EHB1l+>~r(wKE(~p3$lvcAcJSr z%oT5$3{G5gA@2Qma{)9f{|V&d#8MZA4zg0g8DsrK$H{^yK@R zucE3|a}~_Sf_R0!BjxDIo^EqyoXe=3oENaL;G0joT#YBhm|W%dDzx0v>kCWq8=sNl z@^h8c*Kpbc5}pu8n)_VCr}TLB=6t)5qx>y}T_+*8tzNsP%|CK1%}$S^O$IcQ?4F1r zVYe>O&dmn%=2&qVwDc+fH!QGC49zmQ+YM$ZRyqTJ#3XZ%c&)qrqI(Mn(-D+=s9gx! zR(5{@3tYWEcZ4q_db?0O9tu8y4||9A$F_j8#=Vc zFUFE3e(4JKHQckesM~{&m$7f?u%d%0-KDdq!IHX#rj&1dN2eTzc_4<(GWb>9KQR6* zgHd@4eNp$4w*5ulh09WW0SvQoz((U{Lz}Nxvb4Hpm*G~VdS}6>?4E&-&{PIO)Iq0x z!oDF#<1G8897o;j2xy&serXioF{0E%a~>3Wm+sFRQ&;wKcq`qF7&r*KGy z5%nknxeU$m9ku6_dRCm43}G$|Qe)(a0w8=XBw1tU2PO8#TTh zMY;>eM^>CUqJzg2vzT~u#2Fs5jKw6FBNQGpkHw5NN1Wv`Q(2739MQ#Nu3#|}%n|2! z%n%kc%N${7U@<+l6f?&h5zAx#$zoE?5pg``9Tt;ej)>j(tplU7!mK7V)gt=h#=MS!eRNDLM8$HSTI2mzZPESL22TKu*oE z7pT$QEi7wNj(xcr_c4#VA;+Gp#vS5uvs9vm>vE&yRJ|u%kG{c^Za_cTlb($J3{UzDz=7;ya040lG?48SF}%xfke6&~ zJRn}q)`U7~xc?^QcEHhJy-8VKO;;&oUi44_1d_EWrP(ts^zKXi^cwG!JgkoYY8sPz zbHuyq_-8Q&bHp(o(<4z#vN=Lf$3KgiVUEzN<6jCH$q8((g0=5v#Uq`ay@!VH$k}^n zI1lwmY#4VwKs!%a_MChe^2#(xohYR!xxI5gE{N_W!hI_z15v+u+>UQJh4G*GQx_t^&oqS&C6c zAe>Hoo$E=4rK0>gma-2k!0|NUbsWli9Lh#Cc!P0quQOINIPE4tzvU{)~M{ zEKYj-4Da5$AQoJTEO@2Lg7N;EP0D*N8ae)C-Wg$p(z3+};^o`m2VXwXy|N_p>` zduTiU*Dk&t-y?msF2F(Y_=eHiCBT%h8%>E6ULlT zPu@9S1b)QXV~<+rZa$oHw5LbD;MKo~FSxv!XY;X-R=!GauVRkjzH0k?OS8MWf#zRYu|Is%sN_E=sXDu3w}G^7vMu?mU%{`d;3@nS?nNL}~8f-UJ~ zNza8Oxq{GeH|w)FCQ!BH1=4%(!B@;V_Q`6s>6G7l&w~_wof@6UqZjJ}xdHk}9-X5L zx+%EJU_oC@d6%dEg-6m3$Y|IVY)gicObc$^@pDSzcDPg2SdH3cS(Uk55# zp<|WU!*F*_*~-hT)CDR5tjc(Fp)RBnNLfOl=ESt2Ex>ha`@aZ_nbf;V?kjCPA&xih!TRL3(%G38L9p%%GwzAPa|XRo z2*3UOETtK@vu5k0L+hp-JaT@m+!DBIj)8?lahjY5KyQ}6#_IvcqVc1TO3lZ_tKIDY zFj78-c9Qn!B%cQDAL@CI7GID0@~X?98Y?eE2@kSfCpV$U(H`o8AMOi0T^pRcEEf7-NYcs$C}QtjOqUJJxy$`DW6L!idD|q z1W#*mPhyp(3nk#=h#X}NT7-H5ODHdYO*79ub)h-TfgHWvm;-gCYXgy2RIp2^QUKkcz)+wslI%kk{wrti2if*h6sIsqzbsJR!eb#@V}(`H$Myb&s7rrPpq#~Y-;to3)a%dg?q~T2X7R=x z+`Y`psb6A1V~a`{iuhjLmN{N8rWAOWg{Am|_rgJT;6@!Qm;)0yE_Cy96zNOiTI~6C z^oD+|vLGs8D{5?kwq;0TH01=$x$ER%o;4St3aB9b?NW1_)IH@O=%D%fBj?@c!;L$d zy|;&Xvf@hRu<-Xw&A_Vvp7Ksg^N|bg3z+E1iicccH0XagvR28xDJMKzVsmrt;bjYc zJzr2Bg>fk_F?6>?8F#iCUphU+`19s#4nx2Ifri6UxM!^)!1MvUZkLC7YYC}?45_zM znpKPd9jfsfs_`&YHu=H%f+s&#NrC#hvkZC_aDeAij=~&8@9&L!h?ef&G-o9Iy}f#f zyZQH}_|i3ezVBnXm6sV{rU_RzPtB2+l(0|pBf9+l`j#+IOqLbU{xDwM zb#>Ot)XoLCC#_mUt6l+GQ6HWZd}t#-Giz+@KqVoSXOXkkm@7Yw^^4G&$LPZ_N_&0;&;zpTPWV( zuPzKKpL2`4wK~eZWM$ZZf)l&5R_dg*0%apw@PKAfNsZGvU2v?_2RVp1C;-l53{(^*_Rs|@gf(%dzFSa8Ca9P>csQvKpA7h zGngYk2ROb|CUrv1GUc5k7tHyE#zvX$nyd&TQb-fzwK*wAy|+R3aCci6T8Q{JAcy82 zmB#PzrnELOwSLLU04|_u%np+h2Sa1Zo1BF9yyeQDedz7J9Mj6t6Wv# zFsxf+g_IKm8!8FCgoqY?tt>?wAFVY3My;P1XH4m4rDutP5WT~73I4ld3jIfY$x9A~KIwkM(MO@MB z-mhzH7B`ojfl~R(NZtVP_Of1z(5n%PUetdX1a~aL9jn0|8Uz=h`rU8qKrg-uS2Y)N z%3=h;2H*;hv z_8~d;Rr$(x$|;Vr4}rM+93^`VZ9SkINkucwd^VV^!`VP1UTn(Ig83=_#_o+va-l5? zz51JeJ*8E~Rq>H;?|3uTlYP-YL&uJ1^}PUk{9&NOQFg0YHZDlq_o}FcorsvyJ%W_Ub zp;4?5P~|E)Jmo+>EAd?~l!#S+$_keC;vu;cv)3Ao4QMAB=JHXcv&TupOK1O{F6lO? zhq1i(;&abO`B(VI_ZBF4OcAvUWWE?ksPAKE8|X9sJ?%kf8~#Omc(Jqxm@IFO!#j@O zpkr_sy}vh+HOo>@c+fD(?3K=`#F6&YTziySAd3~4T~9}B>ift% z^cD698Xfky0wqpGMjDH&#>?!{5Sy=f^XUwz&&Rij)c1b>v*c3JhOHBn%h%#wp)vxz ze0lF;m?4y*5jd~kRw8%x99QL#I>qMjIX2%v@-UHDinux|)t91*AlC{d_c<3P2YF^19bS44vs4K;z9DmDSr9Z75z^ka3#c!_ZyV` z@?p?9=9lEOTXoWid?ke|UIzC;oo6%_jtbJGI)8L4-V{Pw zk@y!rOzg6eD^JGR>FdQfrc%Je0)6K_s2Zggiat3v$2&jF)2N5ynsUU^-ieW*AmtWH zmlxP|`N|)|cyq-i^+th&obNB-9#3h88l>3>aYhg-kL>2Cj4J>wC0{Ak$`51ZXRkwr zK6vo@B2t8V!-2qw?@phls6+mvqO zo@me%fsuaOFMq*T7S)O-u7P<~XQ?t)I=f+|$2-rcT;NKl~; zgR*nfeNYDq#G2RiUj~&LOB+~Psi8roG_^}IG0g|2NYat~RR)#X;&q@d`DQLt+Hz4v zN(^Mw$Ax61^JYWoMVl1KChT>p=3(4}J8PxprdYsC9Ew)id*hv7lA$*3bIN5P41ZC8 zyYL3Nm#UEu<w`m?oxSwu~L8B=u{?_i0`YPjUToWQdma)^z?ZCL6y7 z0L?SJMb3`Hi=BL*4OAC_Z2Y8!p}>==D39g){qE)*@8&K~=Gm0yC!Tmh`58ocv%6A` zdYW)(c<-+x)#a=w*}D;>8*tpX(%Hks%O4T{nJyG8%wf7&GQAT)d$&+9uS-Vy*h^=} ziB{Sei^E_Hy`t_!)oTa^Yw?WY<}P8CWP79#p!lV;3v-y-*1>MhZvfFDN2Q=D0?ODd zF_y~x5%o3SO%$GqD%{sp$#ATM<(N%5M(oS%(vv~_V^^cO%3e?AxfH6=*nn1R=mMF0 zNTWM-MfdoweC3Oa>PES-;UQAu4lN5PaWYvC_e3V zmBP44lMoyJ{hdGPaHOkDq>hn8yY^0Ak(90={ACllA`@Y2tl26 z2EC_+j01$lw97|cr^)4gah&-*ag_NTF~_#CxO+dD`vqT*7A3(CL*tP^D^5R@H%$k_;>o1V0-h}!K+*a>9(q)hz%}PbbyzPr*b9McvLO7-N zNW&1CPM{P3V%*bA*V;9D*J)_)%MmFCY|JC??F|IK&!ph&AF6Ys4XJ1az5vaQvI%_4Vfo@3HmgIo@wdqv{V4 z-owDhGpSevaVJ9+pmkgzqtckl@IN#F|3ipBH(6@D?;b4t*|=~+IP`y)oZTh2v?$@g z|2-{am>ByFf_fIzL(ss2VT}jT)Ah8FHEY&^@{yN?^+fYYfQbo>j@Ms6W*1|C<|D`o zFS36aV}E?TmRf%r`Fiz~Hz99l^H$6|>?G95Exs`ReovD^M_NADyFb>+tvGBVVayu$v<5~J+Ed;ihw=w}6Bj(#pnUnr zYls}@{`bW3>?}M8q4xpz_YkF6hP#2`j)rWGpq;*OU@^2Gr4yvcn0-DNn(NjgWWB(J zTwvLg;(z%Qx~$)fGj7+BK6{>j#=ldA46=6o&16+E8v(QJTAXdS=*-`4IgGakM#=hr zXF$e~bq*ewJRsi289tpeycx9vS&EbNjon+5V2Zy!wB92}dZ)zy&4fzH=*FW^)k>^t zBUbexf1TIZyz$@i*uPDHJh%z)+~s(P4QHq=Bh7uAKfpUcVwJGps|Rr-ybE-`c6HgE zJWpMAlbzWsv27Rn=)$m#adYtwY}XK3PY47lLTFm|e4jK_*%yz8V(E6{S&auaPB0Ip z+l|M{EqDdD5_!jgS^VmH;|m#v$?gsi^2*P(LQ2CeeSWgPkx;f9-{Slmv~zil!ToKY zg!a1eVq&wq~Qzd?nR~N>fU=PoL5)w?h*r@}iz9Q&35E`<1d| zF5MI4dqIA)<$NR>)oH^Uq7TpV0@x*B68QvZ_ zyQ4cpH(4i*p~|#cc)fCuZ(Ojgf?VYmvI!keRx#wSJ<>__brah0li!o~4X}T_28}?- z(#c#U1MyI|$VfNKS9(C*1JqWT_et@gC;+JUeaoO~Z+7o8R(+0oqn|VavEtu;9=#0Z z30_6nE9YGt-)i0`wqf{?`%}8Cuv@*2xvpf|UWcy-L znrEej8Fx0jo5RXiCUlwh~XEuQS&7A2akK!ys(&<;7SFF@j+#PG223-ILj>7Lp!P zINFja$WorhGi-=7c9d^E@iyLD`H%K=0F_Rlg3Nd;4`k5$_OZA&W0!L(FK4JSG!kzg zJkUlrj|Of{!tLYzc&Q(|J2SuFj^0^(gnD59TO5_($v(?ID~hc%M@vz4i=!s%eGzpf zQIo$7e^YMrz+C?gT7Ecgbo&6GZEyNNXD9yVl&|O5SEjX|6jT zZQRkEs>|$Up}zeS!u8QjkvJxB!81SnYjcb9#m?WOnoo-Zy!Q~VFFNZV`6Rnhu>@BS z;#!=bRbet~@Y%?zj7qjXUU|P#mU<*FaIDUNnDT zbT7f{w==tj(mDvHb5Gt`Iq#hNJkWRqSb)B5s21J_Ms3l4AgL* zzf{iaYw0xDqp4r=dU=}J7r!#WHH)Ew%tK9YjW5Q9CMvWOGy zLs$AvBDDCOtj})N`t0V(zDU3T#_E)B+@JOZ_uR5&?55(_T!i^Cu0HqUm^ol#x*P(JO7H4j{>kwZhq?%hLefZbD zTsgCs5qV}7Q098~g)rxEp!2q2S$wu^S{yaSeIZ;L5SuvVYhQfN2Xd=j_v_{K2~Q7w zml%hKxxT@xvvTc18NOUl&hNopIlOl9gmjrYg`JJT^Bf?u#^aT=r+}gQ4#@EYz3z0_ zwv5qJCwv#Z->bi(Y4PZ(fr|(6Hbo|s-H)Qe*K-FjA-Ha&Xv#z{0_P{MpcaPqrbQ47|O<>=9#49MDQg& zn$1z>@DdBO63XnEeSY6Q`4A~J9df&>)SL`aYW`NgIgad3j8uA~8I154^;*zXsy=2G9W%aMdp)%D_~Ik>Q_5ZgBD=rwgLK$ zWRTaK$yYW!swp?f%V#I#7Pv3;NeM~;N3ck%uJiVAcE{k&;G0JxU5xG|wAs@tA8hH2 z#5VWNN;v_7EgkAtCyUKp+hAsB%Jw(BX>9Q0=dRgt_2-EU3CdSPSS=TasATB;k}jDv zHXIJatDXE|#>RHsXvTfK!}+opm9Q-%>bj;(zehTo(rtXL84W(9pq;V_a!b$QhBj%e z@>_s*b+Yn58c^leDSiIV=W)y)m$PGG0`s|8Ztn3xWE^h8O6aqp~f-a6L>gbE28ff}d|cNMf~XL6a^Gpxg#C z5lzDkPRTv`7OyXn}$CTyNsw@Eiy*u2`Cd6G@k3fnfj6;KG17Seoj z#Fqn8DDX=ikRV1Y zX*8w8`aV&v#XZt<3`2XwI#>gYR?Jb_sJs$_o>`p>@YQy2g|YD_JZEw; z5Tq#9d*~p-R5>qpTiBeLTTPqR<1%DED0~0o=SPZT&A=s#w$1lX&@ou-lCUwPX2&X9 zc|P%4_vW~%qRDrX?h=zm^YnFUdL*U4Ha%!_o(>H(`th16lYP|MoMrf<3va6ulm~BM zH`BIvqW+gVJ3F@eD!f3rwaM6cbZeO0k~%vxwnlHi#y3P>6AyFDIQuAo!=L~F0;J@t zP<&hEbc44B4`sGTB{v@3GHP2w!wE4QWb|K?o36^b6@dN=05uJ3Ji2i*w_KNkZ|arT z*@^_(5a$bTk4#>&EppCmvAeIiDY9GIpd@u?TvW8 zLGX=W*>aQ)$hE5JA&jMW4|dbK#_p{{H_=@*t_b_omA)`}pX|jtQ091aGF9VAGk6;d z3B;~ssQyq&GhPnSwG|MKX_4^qF8L6wR{TaGrbUeHp}e#sJX|J(%eEUc1owKq;JXEH z-%>JGu>tL3b$M3lQPietx9P;9(9JJ<5=!}E%N|27qU=EGIIf4LG`}PWal+M&yU7F$mPAyHLU^xCah_KZ>WHs_mQiRI#?$y5B2zO<;1^JB z#vWXy{BnUHcp5ihH<{MarO;v&nM{|W+>c|lAn(MIDF@_6gxJ{aX#_doH|}ilJU;^h zX&x`t=S}RxkLXkMpy~hQ*Jv>K@$Dd|gnf-W&;w-mHQooIFw$9!4S0hG0BsnGxy%hi z31}mJ-{o%6yU!b>Gu~})qj8WPxh3VO`}DXLC1T1~p2i2MvR&BhHXzgUPhzu7na~?M z5s!uL$B}^0rXAq*9QF{_@_joIJ>#n_;*eAi5y%Ja+fD8BBFNX?A_X|w-Rr8JlG2>x zA!I#`Sj^jqIOce%;n01nd;91cK)lss%Bu*({3;@Z79GP|k=A>AQLMUZUhvlX7kXZ; zL!7At%=?TDXyjJzopRnwB=g3;= zj^Bwbd`FRq81H;6d$EF;R!@T$@swL+0=v-Nsb_UB<@GcoI-)@)lK7ff`(Zg34GXPT zvFJBX$kYPXdb@t5hY`U2ICZ5)j`bJO=v?7NB#P%Dq?eM~!$@-oU;Ax4^ke&Pefs~M z{rB7M|C9P}U*mQ@?Cko^**LLWm}zVnrTf7V`VB4rFhXH* z_-eanCw3T3YjW$9UbjE&ite6M;a|D?T-DbvXSDb1yf4rL9zwkAEjlD-{5SQ5Z>cxm zkGolE-rN211@jTt5V?UGgTyl*hsXO~O=OP;S~{<4=?IJ2f9q|Y`zQ|y3zQ3j7SA(?FyFf=HMja+Nh=Ha#A!8ecO4T=bz})@D*mRjDiIk)E=v7SO z!(`YLA0Y@mFsPqo&keD7PeSjt)#3JQR~lc4ZTw|xx?p@^M&qx*m(P0|k$+o03oG#I zr?Hw@eTe4YyI{H7jyTAa&A3P3xT7^4J7D{Z=>o?rd|!?--Um5}q40v~oj4lI?-=jJ z#jyM;W&-{0MiK$9^r~(1Ha7Qa?c)8h<7?`VZEZw}&Z)Ry{8sF8zlsF}@iUo9rW{}* zD5YE8*Ynu(SRN!q0|neig)I4?`FTv2I$#L?m6ZX};WB#eqssgg*S?6lf$s3 z#)jFrWM!>2HlS6=K#Nf{rTE#D-}Bh3YO7!6tzH1FeiidbCvg?rr4xie%P$LQdFMMg z(GQ9TR%s@zz0YHW@#oFXNH0;=^E?VQ(0|9u2R$SxWtNd3vrhM2CVK;<^munrZ|@?^ zvBFnKEJDjr)XjNKkghWK8XHIA(_^nT)8Jm9W7#_CmaoV6(tOh6ejJAp?jkfCB@5s# z@eZa1$%k4_hnc&i5$?x^G6?f^;rkd2nW~oc+vS~zd(VWI!r44>RM0h0ZYJU7##dNu znrG);e5S@^c_`6*P}=T(mGBT(dA(T58~YcQ26%wl#EcA?yJVUZuHrp^lUqT);k#S)u=kmF+@E?#@c6)!b%msKVNbVUQ_`@YeFj5m_Nc<8VTr zb2;OErf2WCY5ZU--Z$Fg-80X=$p|Vk{ zle1XopRi3rJ3*x zk%Gy6{iR?X2@A@|8?}Q=!GMzM@z!ah@c$tx_~d^n1$X?g6nsFHf;a0lDfqx8q~HTT zP6~FB6s#Le3MTdqCI$H#qTur&Dfl5t!R;gkcM<)!lN20!kxRj*AStK_K~k`Z3x}r! zCZygZbQ1empS?=`v0W2V#A*bavm1lVgw*yRA%)-728LU%XGidGvs0tKZ_GjaYC6^N zc15s+<%+G(NW~M0PWN>WEIx}BXHAh`rL7<8P!I%nX-(pKuRZ$qo{vM?71E;PGAnsh zObr$xA#J+$2b!}>A$+{*bV5U3nSG4TTzenxB_zzP#>Ns&mGRc<{4{;F>!vZ>)$S74 zrQm)@gBPWK&&T#?V1D+1{48I%-R#AT(ubZ`seqp~K)i-cKkoA&2aZq=HBpnjf!TxM zBJZTVIjY?=UoY?6NcC=vBO`@dYYgtq@q!clEi|_}Vc`|tGW{qU_Dwtg2kF*p zY-rL%Pk_-x?0SYhlIA7h;BU-8{C#}Y$r`}%KM{(*vEhD=JE0A(HL|)^<(=RR>I7^| z|50ljN_8#ldCpA@Y%G>*h zu?)1B?RlQ|%UV;8>XX?H8=Fvh{LYP9IX0Zl`)iDK7m~%H+umVh^$lr?pS6vG3)v`l z_i`B0!|wA#V0Is}D*QZahxt{e3~dD5+N0FzMAA1L6~j_a1n&v@pD(99(H&RNK4aah zwB5RHtvFQOa2-DZzb?qWD7WER-IovGSzYyn*8O9&YgzIPjYlP8$^rVA&a+TYr_<%^ zSo~hODcYy>wAA5Q5)7Zo&F^Uu5b>y#?!Pu?`BLzh8$6DxKaU4RoQC@I{*MHqI>R3J z*A8Dirgr$H8}YSRoKTZw5C3b|+5)+6TT4r4yys_bb$O?Px_Fa+ET7v;Vr(1y>X2h!nV}O6Mi!1ClX$L zZ#VIV;5PNf1bT&XEo*#aLz|eOk;2f>CPl;3zSIOf--z)B|3bsI;b|U&@r733RVfDm z^PMdK%^Ul797qTh+Ga7}Tj%>=zIwKLLLv4(0do-p3&Z|Vcz+#(`Q@m zR+Dj0in}e$o5dsAO>*m$c4-L6`3N+X{C41Mql+pR$OPb{5U{B(BnjC=D~K^)SP8NU@7>K~vVW_EZo z&v@_%+t}bUart=dj?#pSq&LRmfkf?1{WyU>ZVd;RgB(bd66DQLONSuNgG?#%sFn+n zGQD(1W@X1(3JsI%KIt1QsR@$&H!2(5L1!7Y^DA^jZgR>ACl*v~^nu<58tE4&Qu3nuLwJZ8? z6BFSHyoq!1+_*Rep1_+xw;ki#kyO1Pjct$8`9{Zd`CfcUM>hhEZ#B0{kxfQlSkpK_ zLx_TP!)6_x4v0-PHoB<(IQ-6`d3-B=WL}4FSyIJVbGs;^;<1c!#Jd%pu>r5IrNCD# zAdbL4!zcEKKI{n zS>X0EQ%vRrJAO}-K>W^l-{2p4_G<0s4Pd;NpcjSltuNwvY;#M^WbtZH{;s6A&EjSB zQd%_6#dGG;ID42clFt8xuf+G&5dtwxBzR-Y0BAs$zpdgJrTH^WkUTq_rl>V-_6Xlm zHbE1sqtKDt@a(R;Bb=_pu4|8k?ia)bNQ|rT&q$>v0LaDw`L0JRfX%+~J#EBxJ6pQj z491H{rP>lo-Dm`CB7i{WEp@VVYZvnBHjXSCb&d!6WmHFR1+7d zCX82ec}nx_EqddQW?!5=98Wr})ILRXm&Oa8tk`{7argmJ!-?APx?ypz<7YdKA4ymB zw9&8|LDwa`RZl}w+BkdjMG>;q(H$&}$HkG49DXoE=s7$-GrpmDV-&zb^I_d>sp4hz zOX4A@uV13a;ptuytM6%JUn~q+64%p4UnWGcOkv$^CUFQ08+@_dZEu2H2ATCkf`6muH-vT^Bi?>BwwCi&6l{qKW1NNg!-J367F2&^*vvZypB>W=kifG0GEX`tU>v-D{@&>+P+MMEG3>zK-DY0MTr8gX@T1C zHnxkL3g0}g#NmRf*Zqdcv$>ZfFWPLoL(YCzZXszsvpX|hC*A;XpbQ>5@Nb1mJlXF8 zDET4>^5(YzAXt8i9K$|c=neXGp{~R*xlcU}boVE~;hktxzQ8~7Eq11U0ZWv-@|8kN z4gpdS3S~VO={7dL5A&_k^a&CHr`XPYwDb*=qPAroj!pGNyL-bSB!$Hq3j8T(*#BwJ zAGYP;0w|{LpiXXS$~&jk(Uf;sJyN;CFgc`pphB2-M$2#=e&Ug$e8RqBZ0r_|@bpn0 zyOQjBx;4Y!p6N%~8IN6ZHi4++PXSsj!iCcayf|+H6bxVPYu}dXmk+j_k92=L?~bGD zk;kTt;nyJsP(2`V#}SC9+Nm$&re_SFIzg8Vf4ei!EtJLqdIoXKpj2OMm~4`zcVEQ5 zmD}kf_C~z4f%7$J>#Bz@-a`|#ql4=`&&K zKk|7@i~BQOcyr5V`k2<3eKBn@nHLG!kWXau<*%@Dz*+N}W4iVt76s$?w|p@w#ozC< zMI&fZdif*Y@RxB`^hMiKSNkGf#{QApxd{fOwvJIdOr`eg>|5E_*|)NLj&~gPm^PCBRr<1r6WA19sjh&>8>0Sd6tj30eA z8-wAt zf^Bs(9f^UM5g=# zd(^6wX7A>XZ=N_VbsV>L$eA4z=Z(Xc2M^(-#6I=}nYLMO&7t;i0V38a+f0-WnV+ziXP zyo>VJaw`xQ_{{yOK|UDM1xyCdaw})DF1eL89IDfWA87elAM;hr*BnK_&+m&S{5D^7 ze>U$&bL@WP*aOJ1M`GT`7a|5^fw&M%fl_qu(!bE`kUh${=P*zV=te}7vpecPMv~Q^ zZtlQ+vY3NtBRCtWiG{)fbX)}`*KL|u|1px!xTh7@u_{I93HXYtHDA2C61K z>rhK)9FSB#0@C$kUC##vR~!W#h&xO@$Lzut&CptCRSUEV)mLaHdor*meazvW7GPCj zB_7i_ZGy++_NXfkfEs!lx@;9|O6D2pNq0wc44`%f@NfAd!Tsqx`4I5oX&n?0t~h{I zo;ER{aWVVl_Mi&sNvm*wPrF(nQ2A3+T+bh|$di#f!}v3AoaYJTQL_i3uN#giG@ z_6Z>t6i|HRC3vz=qAr(rCRbh)?^)9U@^2RI`~Q*-568)0c{X>sd-X1ZH~X9?`>ZcQ zKIDyFAgpl$7q}z7e2UM%8VK_(l*xuRIgp#q87OvP;8%5ibaQMfbihd|`OIW?&%l zdEB)8{_8p>E5G_c-M!eRzr=MNQSCZ31M|vPX(~oGv+kM58xEuME z&!k1X+3$XiPeCyYYr^X}{(>ckLJjtu_6(h0Sm|qmI+Pg1<(f`Mz<%#7Y7m&&J@*Z~ ziY325jPuV_Am|^t0*x5;g3NpDSQ@S7doMTs^b{GJ$5*iK-eFsF9ZUX363VFG<|OqccxpAZj3{x*x_A`f{0a`!Up(uf`=q1{4bu1~mJJp~TSrr27Q4QT1VF z?}t~dTN`vGVpqQ_5ku{I%IR_WvMaX0xX0k}qJc>{5=n9c{<#+aT#0{hlSjOQF8IF_ zcR0%JalT>p%Rng@&NmSZ>y~-55^@TZcZ$?C;{y3MgZq4(80S7eN{n%zzfz2JTbuiQ zm&uvUA7;h5J3evm5C3q@Zck=27WHNsa+VcvE0^BPx*Ww|rL|ib9`*A-kMYXi?GSZ~ zVbv-zUTOTi58pJ$mg@xwV`TWp+nxlVo_p#nXWj;Fb^_e7rJ(x4b}p zbC4S3M#N;xO!*iy~;=+7h{m$OL(8}NO&FAn4U!q@lm-*|OaRvF`hx7^gA*`2GBtK7O)%rP_Z%#Sp z@e*<$RgWvkl@D=f?-87>$9Xo&BC}p4nC4T?TRDN>5vNS~Mn1MJ4ESx^;!(tEoY8!& z2ty;7Fy?QKjdYb=U+1Stg!8UB@evtzg6L|LZ~fJz}QrcNn&Gz zd>_H!b=(iJZ@q=DGid{lesW$cjcK70Bh4*XXhDeqKz+vb!QmH=q0>;uvLD~-JzRu_}2b6Zpy8I zqw<~y&pAq}v^EvtV>`O#xgjYJ3pR;_KGNzWD zL+SEO_1DDZEYDd+cC$cJ&COA!(M9Tb`{q!^q=AFMUDemW^`W22C+DV|$d|wItU2si z^R8#jG0&QlpuIP(IpbNQc-EZ7tIF1#^Q`G@T60l(<8GRO57UbZ_d9%NR+_9laksW* z8>`qML6*8$T5P^wI_4jlfXd`C!XzqRL55p}AN%1}?J=bqAJ@ul8dAs}Bb8NeYgD=I z{CDg%16`2npV>?rs_E*WtHopS{)z*1@rT^y$?PrgWM7<>ogl{Br>;U%x>aE*2Ru@5 zTF(hc@JQ!msW;`Q+}0z-%WZO>d@$uG1nooQW8VC*mF5;)D@}4YCzq1_IQD6NDqfI! zLX4ErE<&p;odfR*sun6x{(J}(Y*h9i>cu0DzANc&zfw8UxW`!PiE@7##=d$t_c%Wf zxiKg^HXqguBb2g3>T+Q7g|<}l;hG`JT&m1>8LTm*J;Javf{?v{N>BAtB$iz@U*wv%iNpeqoru1b9?4R-6lQ!74QzrU`rzFNwUQc zy6DR`pyfPV@p!ToIXeMgy=I@m)lbB0`=M`Aj;5TLkN57&t*@KLqpi6(xebYx+*R(595TSBFD_S#6PaND>A4JS4xpgm7HTY+}s9fl1jj60hVa9v$I+nU`b;h6I>!zDOGP-xusExylGf$dZTw(HlklC3XzKMWY zYSWr8po!Z)*|z4(ZEs9yo>M0!8((+>KY#x?64vvX9l#0i%bQP{-*$}!6s|vB>dDe0 ztWgk%q*FnDw4YFafuBaXK&MfBcm^s?sQCoS?91$`BNhJ~ZZ+-8?8VnZ<`s>K^KU>9 zkS1ZC;XNPZUvYH&`^%L-;XYv0vV7&aa6E&eDU^;ss%L5J;qbo*q|^8YV^_pSr&zUHB7zS1PXk6MfB?cKqz|1INE|&wcB+fP}H} zNhs+ZngiYZ(iOP2{S!R1oTGj3{t3UNl&*AdngBv|)I^LH|Muar6I1H|>|R`q`mRxW zD?wShf@S?1%laDTxgPW2%2TT=HMA~e5tO{Wjnl)};2t6f{{c;mjmx2cPx!{T1u~Z! zCy%TG$l&h>*IR0I9iCBY+|pcXToJLI5-uDrHNJE3PqYoac&y>p7h=goct|wIA zE96=D8~9kai9YrPZVSAX*WzMBZ4#~NckzcV7lP-N1JC2B0tkDeJOz5?rRVW{KrFt{ zTVR-iR_Cv1_0%MI7R1Z@JO#0yrE$1j-Wn?eo0_r^jJZRkk=*n!az|{4u+#8 z)#AnjTlWLJW>>2!5p$Kq=Ymdv7^vK1zG=D291GwNVl<9fTG-C*7z}M+FhS>8U{agx z8y2W4+&*eG0zT%K-t|jol_P(pw#jWlHl~(=c8MpqjC&5UiT7O?g6>-cZ@n-9x3Qb0 z1+OCqqv2ybvY4;jOXm4~rQV+ilPO+qsTn@eKM8H#rnYoMdm=%?COU;YHBGkC2b)tW~-ya@H1v1~G%J41 zzGecb%Q3)(vLLTCc-G*HTSKGY*oYRa%ss}22>cMjuQUD>?RUHTbX&8?iiLk<3Abpw z1CMWXZ@J06SqXDqSGV~KXzCZDNtVuRTc~R|AdcEL>gG77;pQX8dtPKFpXz1_(kJ%t zm7S~UqJ)7(H+QH-N2^7*1Qo?QT<~xv8Ww=5@{D~*uF~gVpS)zAM4ad%lUTvK%wRrv zMf3Pp_Qe3b9IP3wJo`Hst0&Q53GJSSzx5W#)6qG~7o+K}QA~)TgbzQ2O4|$=f%~9- z=_Hy;JR?gJ&@{QsswBhk0>h>NP%p3#$yXdII%hEXd7(c#zZU}Nj7M}X;%yEdRA!RzhOiOP#1>FNLC%`D^1^!1A)8IS_scd)GGuA5&SwTV8q# zw_8TzLaRl`3{}*h$G@(dON|8wp!9*4$kf%@vCxRc6Euwk0|w@iQeywq?}rA)x!)Lz z4O}rnuOhz%>USMALEwmLRRr;Um?8Q{zE$N5SNE&&dYRX7J2}5>9_VVy6S-um18*QU z?#Ujuw`;2)jNeZdG)m3;r7Y!_%W;0dt^6eZK1U}`fD#`@l>I$j$c276*79ND1OpVX z$J!0bj9YO;jP>0Hv+oiH+&5QwN`X*bFWCe!e0ch!j_x-y?oQ}#Gf5*5#PI_G5OKbj z^FKVE`{6OgNX2{cI6V$1rW~al#Zwdu=Y;P!?n#R`?pYP-ZcYeqfhG7AXv?fB*4*bD zA}=(AA2OeJM$dOQPd2~fRJfhkko|kyjp{3lE6)(hI$lZ@N*+N<<-m}I3TSScmf4%~ zw;XR~SIMK88bEM>X6|0muor%q^5#?e&;{D5)j zkz(BGN4tkY%7OY>5rUv@JW*YJWUOdl0S$ks_r>hnVcgF)NLLb3>SyOQVhE< zXZErLx0lMs8P^T>%K zpC36Eb3i)R^NqZ}r({C>&)`p7_z~wYGi0hUfDaj~4O4(suUIi59()7%u{|I39CLpl ztZt1Hg6d~f_s^ugVkqG$pAhE-H>D%c0T)p}IFkdU_e;HU=0#2vPiC*yEn2hc&ZoLY z)}1%GB8`u?zSGerA5LkmJ3a25kD>7?N85tapE~@`=WW6n1!iH^sxN#MWZOOuaL3J?V2j`)roCpw-0 zI({X00SU(fr85BJr<|1WK*}f}Wn8sxNy<^VeW?_sl5p)hjflAggl?RQQfvSvCP*?6 zt<58_;y_Y@X9e~874l++5AXF@*6T}am-uoC;dq%fIkq3d*zpP2@$nqnINtHG)ba7q z;R)c!f*-fERz-YWzs`qWCoI&0qu5Dwz{5;}X4Q zc;nI9q0H`2Bt{-+J9g#CWf{T&-0P{Ms`&YtY{TXmc$4g5ydZaMzT9Qp^IoZ0iWQCS zmM~1{q9jRKiprmd9jqHQfTR>JI5)eLh-)nNgR8^H`aw+sH%Tw{zI zVw@b&u?%M!sRi7@Qlk(2_tBzZ0!Th1z{fLcW3lcL_OO-V`+ct%Uuh2Ck3)I5?-yi# zx@AopN^*|yrqcS&hUdbBW^o7vo`-<6LOrU{HE&nFIPXt-{4^$N@BJWN4z&F#439Hi z|0jcKJcXiHh!sb5Nd1n;*&vn|!f2Gqx{u`aE$bYMI8Ts#aE{Yn6?k=e9Xhr(g*N$5C)0T9tTr`T(HYFah8;{2VmxTQ5TBF0A2| zBB)`9T7x%xd*`u8MC@4SVenKUy`Q1?J+wE%kuOPIy3UP_)U*3z?O?GBRP|srU<_Lz>bn>@% zULV1F>l(_EOv)q$>CJg8rS9s~^V}EpEcZFRmb+8W0G`&ftjGEDkwBJqp7a_|dO^>i zJ+0R&dX%EhbbiaT{34Llq#xKFCdL@OKUGQa#ZR$2)&mI)j|GtcLGBt@{?@l}{A-2( zf`t{N|C^6u^HViCt7W*G_UMz;3k3i0##9m0WW1^`95*tDjv1B$TCR!nJLuew3 zuw+a;TMCJ{h9;ha#1R_UsiBEokl1}cBo>xIVq9oq2P7WH#3oGq<`2P%+cq)sy?DP$ zfoJYl>p1X-0oeUqOMLPV{RFH$wCN|fr61?K{t*#EX?%O^GCAj>yJh^8R(JD7ckj5G z$#Uzm9OcIQNzCnwXn?Hv+ZTbwQ8KhgU~!?Xv-88?D&(OLYw+*;LHO@^Is6AT_z!FF z?_=;i{oqg5;K%e_cQLRT})lAA~lO;Tt2ueaNEE=2yPd+VSvXWphE|^E^uMhP$#%+!QBEbADjs8K5!3$ zdlFnLxTE0Cfa?MmTcZ~ygUbSUJGe@4b>MyjZa27ga3{ej;Nms{?ZI6SZWg%t;8uVu z0p|qQ1nvQFe+KsgxK?oQf;$7Q8(bu`Z!Ea!;IhCKf~y3#3EchQ{!q*MsPazcF9v@O z4_^g-dlj@5TsOE_cz&{qrM>l7s>VA>kEROcajC*@!0iTiKe(TOO9f{FmjiA+xMXkw zxVX4L*}MOcDqIWYtG-MX9=~zE;9E6c_@HXO5PxpImM7)IG-1fp`9k9P`NG1kRAI1R z-SP|}XC%~jRjQCzc}JzArqX2HSZu8lZH`Jv-yf77pBo4lSxQV6r_)kvsCTCD!j2G zLuif%I*bK(Ikfdk@WC~Mdl;Mm@VdZH24})Fi_2BxaF*O`5`<`gUu`WG9nMf(?k;qS zrPB&6l`b3oDX!WuZTigVg(cR~(&WNQhh!^uR8(0+j3~EMZkU#wl5%%psiRU9i^?I8 zTv+O=vAJA@rB%*5rx%tMmsy-HtC(zBVJUM|Sj?Lt&$PnQN?UnQTDqm&R^+sqH*bde ziyh?-Cjc{<3!T=IeySxcrT zo2DfvB_~Zwo<1Xab78g3T62f3vSdb~R6NbZ`?s*l;w&jFa+WxY=&y_Z)=UG~PFEE` zD0CG~o1R?gvf$q$*1+jAOu4Ia7cE(lxqM05;#^bi(ySE)Y0EQBix;J5F3HU_K|*#e zW}Joa1NDT!LP*nQn^u%rT~<@Mt=L)#zG!t;xPWu4CQGHsg3YY3R*I$~t5^dqHs#j3 zY?Y9w(o*g+O}3b7${gk8wI<+7Yl+Dv6}fCBHj5J?N=@0eVyDC9C>2fV4ri6aX+e&? z4#1X}Y8_IN$&^!WwLpzdE3lPVX01pvAuhEhOH~zs6df38LI5?04wNCZN}#TChouC$ zxYX&W2tg?cm?II80`i+|mBr;!iPc45Dy)E)$x+GE5SY_iY_(Mb+BPxKWGe;4Y8eJ9 z4a!}P04|UkQd1~hOKnqAZcHgGx7{>7IfTgKMvR$MbW@>dDKbe_B^J@@O2k4xTvjdR zk^l+n23}H2Swz@a#i>OW7t-JAtcGrMB@&TYB_U9NueD4eI0RK|D#amLW^-wzPc%uD z04m{u&z z)L1vsv}$RdDJ?xcGiL=Rs7#C?vnVZNscFg56+v0D6VhN0rU$!cfQNL zkZ>`0L3N8|7EU;5wxfo?abnp-%w24CiWcx|EVW1plg1^`4ZM$kl$}xI zwE}zjM<9^MRVErOj>^F7V%;brQxbvuF;4%Eb|qelYl52>khc|yrii7PRRisR^@aQqo4x$Em{kmT0C$7G_isU zqEg^=$YZHyy&ob48GIbyT*pBcbvYmnxYbqyR0ftJPEorVhy%K`2(mD^7Sm*xR9uEq z4T$YTl^&S9iYw_96w9zSj*O)k@kZ&6!$?bXS~u8WRZ**@UzcQ>j}jC+rJo`}N?b9n zV#=*08yFci&5lvlQOY?6`3=VqOe3fyx~gDc+lp-EHnEm?0lDbEY&Ud4HRLZr^aE-t zhsg>OX96Z50XLMfSra+3-yjampfi(9fmt(SX?k9E=8_eRgVbz4A9Q~^Ea$Kld6hPt6?2ilX~INEAhTGX z0=r1%Z2Cyz6V-s35hnK%Pz+#P7gHJD5-emX|2~L}w1X}nm~QW~vU2LcaRxOI)uR%u zl+^?Sz{-g1WcBbT0F;HSQc_~$2!#w>m(^;zyRaA*nG&u?ZPxMp`vTP+vw{+@iB;m50D3q$BR)114%x7usU zM6v4T8*ca>u-61v|HIh{3$^-N0H0*Qr_xbrHECQH!e=)H>z8>sIZKzXFy&@0&tH_D znVZD9jHK>-Yq5p#xXl&9Y}dfpu|fY_W2&*LdX74%2P|}GK-k0}T>9ZNE12&=hCm%@ znkI)j5l5+?D$B&6sI3>Vn&73Hw%WVYGL6a)z*ZB9(DFnRn`=-HBD}Q9`6?~I-h%=g zunxMzYOShd+E+5piZ>t+Vj!HJevQ>bi8hU_j1&}ZS-HwY(ZNAY+ zi)59(iT|dR8m`Vs>>|e3J6|~@(v1a z5lzXwML|Vrmx;>rk~9ONy2EEvgu#>%+U=?-b5vZKvPlEm;IO(#JQl-pV}lio{zxQo zPwbLuB@P10mPRHUVG40BN0KT!cwcey2XAs@*j&WMY^$OGcd0AIT21nsOcc80<^oCo zsSuNN4I^Wb)xuigw7Kp;kWBTo;W9jAo0RPZR65kPovDV^Z9}=SL9$@jiYqm2XC<57 z2Xu4LyswSC5PA}wXq%joj_o-1lQG4X%0Q=JuZ65f16vD&f=mo;mUUy$MyIU;1y@N> z(~u&#Xo9Ll3)7!*c_CIJ1xS%5{<0NIxRl`vB8do?!>8IROYwyPVSNcXf9aWo^?mSu z(qe6)irN-dl8M;6xFlX~#f@4%HD*{UEE}w92LQ!z%N9i>8wnvaAc-cr*aU1tjg6N1 zB{nAxqY?%QW@OS&lX8F~w0HnEhe@CMRxpa9dP(u&aaebCJGx$bn->ioR(rS zF({7ndu`A_4jIRZCZ^~EDYZdRY28q6+koq$#K4LXCq|{1V($uoX#sgLRo?1#A;d>6S`Mi6xP39CJDpmt!kjgKVK8EN!t*8}yhd3)jMB z!TUt6qUke|3;FxR8&M2doX`@}&8Bo9sbhn!7#38f!b#TIqNP&QaKi|bMKUd;PzZip zW3bX*vxZ@br4p41&cV1vPO7}*tVG@@woQf1E{6ncQfVp$E(WkSS!@IK_+SppT)A{f z;iTf(%h_9lj7-ylwB>0_RxHY7s@US77F63@gfjqvsfEAR0DNH83&bo1oS~7pnFo3A zl&Hx?MM$JTSX(ZUtS;hSM+L0nt&$VO7-AmOQI)vI1|zY;QG(mG z&Lmj=m|z51%W0d=p-KttWF&66HMmYIZlt=jhPl`*U5o+~vA>Ra zCRs`_4pIqAqOVj5+6@dRZOTRhSVhcS!y=YrkRP!$)*XWyj;#&l~xs@DkqI{Yh{Vk%8GH`46U|` zG%~A;v8+q0JkeB3>n$fv+ClN48X#QI4y26%^}uCvNPPRxVM8!hVk6ELA&jo=YpaJI zKr)G9h`fhxFUG0ZbTj4ro^}7=T7oM60$chx_uwdil?DxQP#bKj%u~lctR|(6n%+PS zlDP69nI;EQgX<5awha=F0~&}Z97%Bq=n*1Q-y|k1F$32jDy%M^Xa+*@HvUKY5B3Q4 zFUaExzGG;S04Rzbpm?>-Q67+QeCQ)40il$L&%tH*&NNdh5H?pda0J8rhB2y^L7*lD z1YIdIBx`25h4myQIh;&PTXB?AlEX&g3@8T-tc~GI%m*NnOs+|!aG(?;3@qh*&#D3* zEiiSuY@q`VT3ZP-jmu)=Ev|NAkfNc874a>fgs`dsPC#H`duY1goQ`WQ8=nTMEVU%$ zS&Q%)8agRRn~=12Yg8XdPHm`ZV@Vr6i6MiB(57h%n_X$47cX3nyBu`N1EfC}?jM3kl4ff92PF_D&^0k4+S`NxG^gX1TtGm~^+ znotYY5nYz)$-vX)({H*F{#Hok;xszC<(fX7^3SHsoB24%6;^>;BrFkD2${li@Y96F z5Mu(jRLFwZ0*F}-X(otYB&0)ViI7XrtVA|EMMJnuunI14CZSxg3B?eqm zWD+b8vXWm(e>c#xRj8o+BE%IzNEB))wvzCug_1U*lEbNlQsodc8GIAuD-#^>Tn@QR z5UPQ6t55=Ammmp6RI)^{QN1ijDg4bQ44v@gq8O|<9YRi_ivC(4#s;BE;W~~hLac=t zNk|fMpf;;u;jMF0xP;nSMlDI=)TpJhRYDcVP81wkE|XSsjYbm&KR}}r-kx&sE!2ui zpqEJ~CCn>0Ws3n`CDANNs2I?$B%zq+y@Bc+oR*24k~V^7q8!DLB0;TI!4-sc1yR>V zw08)V{mQ8=a1vfth_B|oVFL(>&mrZ6{b00XO(8F z4P#vtTP2hS@tT^pCA?snrOPE^C`C2Wf@`VZ;IY%5MceGGSX^e_C!8J(l zClcP3grAFYI5^Fn1L#xC`?N}z zD@dj;hQBKyW~s1*TDwHZ&|0$`Ldzj-0kmla$;1S{vO((jc*Y*e%MI!OvygLs)yH&;l9cH|KKmkL<}=7wlt#U>Xt^I8 zu#(&<|6X27Ifgh7rX^u+k#kFMuYCUq`XLT=35oyl*kSa-KD$G(hK$Jn zOun)&YB+#FXgNmTt8+fXN&x(PuSxj6g3KEAu)>K8ZtXP}>T%60S!j&e*^NdOr z{pPAtE^k;Fi%=cH>zAIPgYiyP6@^H=U`n{S{$iut zh})&wGMUy0#eBAMX?(+QuHb75#$`w$HVc#l#bT{$%R=eG)&o|-6{Jb2QgvWS?2!#L z0z|$d?qB{o;IR3cW}tH6_pe+6E7xL>j+G>p$_d(r5Dpu-#7a>blk8X#A|uiUp;U0cm2tkUCi>a9PExFKj@rfmW~#;@@zJTa zwf}fE@l|&~^HE2vI%5o6?WpZ6A*mv15ql4QLnwBJFX(PCVwBb<7TzY16k>-JYP$Sg*h@HD& z8mlr{)dcc{oFL9*`w97cJ)TQDI#!yK8P4A)%T*q|hL3g|r^q#gsdd1brN6BE;k~fH zxU>nPHnNt`2x7a;X~F^uWomLN`g`{Q5<_N+f%}PSeV5t+9JpU-`Y(0_133IJ1qRNB ztY@(1V4cj$`AsA()Orb>HF$4vuo3g)F=M@vM%={qbJBo!7W2LTG@(F<{vJ7%)NjuX zXNNlliQ``Ky%>hBR4-izQmBA>p^T3Lkw#RoMj5=1;^1rBfV{DW$TGG@44pRyY9Rw@ zu2M9x+OmeMbjoSfAIg741jWL&Kvs*oE6yaOg>=5aE>U3j4y8yrmt<;uK=xIUl&hpw zMHMfv%E{n-{dI%@#cH_LVGZiPa!t<(rek2P2-a1CTb$TW8nb@tPZhSW!Ab{XQq1MB z#8*nHgb_)G1$s$smAY?UO0a|V4lCszyhQM<#(K@krO{H-S6rGrQ>pd8$@hb4f5m+7 zhiU1FK~M(kPm3T9C}ZlkuOCe#c>(Q3n;?`0|1wE;iT+;ss?L?g#AQVk9=N+z&N;Ze z-wx%#-LAk6`lWVDL%HKp?GEU5q3s@6W8jTapzhH60-BJzt_ao6R2+hJ^1xd-_71Y* zzv6+UAEdp5Fbd4L#eDy0L(pjbzm|gPTe|z~1XDr#B`mgjN5=yM=&0GnMB_B8^7nlOmGK!IW}Rt~<0A2d{*g-g4=^ zhv41&O2IK;H`2uMWjj|QpF0w%q(mte4prSxbZT-jSi}7Ou{F3pI#9Y^nri~RTrN0* z_L@~*2!8MT{j+5;wP-N@Mee`!Ztr046n;F0Ov}DhFJVoCzu~s=6_UCmJ6Lb2e4*~< zmGM+d$UcO+gCz=;THgnDCIZsLMr&Ag$1QaA8JMdo`8+mNTUR(aPX=hM%6S|^i$YfT zrhe}oaW#wM4c|Woa%Az9Esj7OlbJ$pP?-z}Ws`oM8S;*j^{@KYU)2?YCFG_1Autm& z4h(4NHf`+{csEeOC2BF3jTYkk4ZKgVWMDm=L~ooY2s7btGW<*Ghp)N6iQLT+p z>r$mz4{V@Ojryor<3vV-AFsiyt#VR4)8+@u7b-<+FCp(QE$tJzp6t|C0p)^i5P4Hc zdRRG0b#+H8K#$NfCF(j*{10XD55H+*XCK56-K#&xvGD0^VFSZ#@|JhM-0*49D-|*-Qv)7QU9@|Y8wnsALL=Lq=uny>Rq>)N<*N=P)tKv0qw?A+O zNQIQ8?Jt)RC6*H;gup1tXpqi%%RxG1F^v?YZ71~stF?doI|&nsx4rXw+8(-MvvO)O zzkmBn_*+#Il?s(|BBx_u_erABYSUoZxR%18yC{c??`1Kq0HJ?Im@v*50{={ciR&e( zjT{h`hiESuv)&gmKzYx!QExrWJstIOoFaO^4 zN2M@HJ0s}im{{oTqe=mbAZc}O2%3Rxf_1=Z1$A_jkgxS&6@^d=-bI|=2_-v8mnr5N zo7(yk8W&8*8Mq#)Bw7TxqMB1)qLNZb`B44drRn^ZOYRPp5ZzosGK;-?XF7~TdS#;4 zvzOv>My)C;A@UO+CE95XCsAfGKM5;QOUi@ZRyzjFpz7#SIkH@^Ub3BR)>fhQ?P@=j7;y+iT%WQ9jgH*!S(%DjE|p_~rL2p~ z3AQ#;$Fz<2YX#2}+?#BK71PWc7@g{f*ud)uUTvt5ZNwSDvt9?+xFl^PsSsxpPvJ_- zBuwBZTZ5^>@C>C5j^0jAof0mQItIwA?`vCC&>jG5M?ku{25Bcdtq|BHc517n!Sl1V zStnncNy5fKS7$Cw1_ZwockmppAPJ6r9XeJ+WvG?&fA9!MrvR)#Q@_$!>QZ_e8)3zq zlbAjcO`0W>Tx>M59m&A_!`5}SARVIrT$RXEHV(3;3$$43x4M&r$(N*Y=o+t-)+v%E z+XB+PM5BH{whdhKi6OfLQi$fFLI_>!Rq)tANi*-AXpIM))ZgV?e+}%nscmKN6`Vm5 zuA1_c52^!%&M7LEgU!8(TKc8xQFo^qKEbQM&VK7dRl-|o6{cW}O|l#F3@6_JfCJE8nJ2liaZY6zT z5$&Qb6Y_%ADs0YkaZVa2AxcR4IH_g*S8c8VdbORX$-Z&BQ`4XN>mp9d!{%HIwLsNH zCIyZ9fzTPQ)fy~I2&b@dt;*WLxf^-^$F-aFL8y)$JPHQGPvwYet-b2oa)qzB#s3gr zRhAS7Y4cM#->B}8IQscgXt4p zn8qyQr%X>ODKE$8#r?ikN*l04A<#EhD0#|wTTp8)7ubCPp?l%M`*i)!*xjIw^eV3N zvGG@|$qAGWQ)$eoH?s3nYFx}lXLJE3c}P& z#NS|Hz@ZvpYEW3~Kg~93VU4Y_#8KliRf>hhk`uoH=C+m*uN>m#64G@=VF^D8EReKf zb4-=`x%yzR3png0D+<#Y?0g2hFc*z|JGr;mT5T;S9$I3lbg(!brw2|^Y>k)2QJ>WmYJ z&mc*{3Uld# z6a=@ZveZ#HF?q&HbV$)cIxnNXtyC$ph@Xut1YDDa8Ny1%X1>L#0bGH0vYC|$ ztg}$ar`c#Av=v;Zc7^EU_^Y&v6|N0Tg5o_Qg@9b)qMUTR1sEkzIju-Gb-l9HlXU6>)w2x^Bnz5spL_^8Vrimp)Pvo_snUwCRvQ{D}DttYcZD`ZI9E4d(5~KejSAnDE#u+)x zK(-*J0eP_8Ao)xt6S5Cpc_U0rp1#r~P@FKa z7-)c|G_s+omJL?q50a%8C$K2+dB6CHfbql<9NT0dj9+Lm5zR00MzYI|(1vDtc{G+O(#)+}OL%=ShJ_Hg z$eM%D>E`I5JVln`JEW??a|yzHmg*=SBm+0$PJ=KhdO*F)Lq;BELmMNt0Vu47A1bF( zAUc==rokxC4-T473*14V)f#|*hczG-{*$?z@v>N^%4$~ce^_VqpglWiyKIPOzQCKgY0F((M@FY!J_LL!^9!C&I2v|{Qt zsP{4vU=pnxMQXG*l0X+REdssUiIPnv4wEsiYlvZ1W2I}5hBPKu)K*z<#<9urm@qD# z8=AkFR3cSW1(BZ2uv@sTHiuK4qL!@S6Alw|LUbOI=4Mls3(AxuoYjSes_FdIiE3#2 z>O`2g)|fV#Qt=XlHKx^5CGEBYc&O6uJ6N*@6J6lY^cXY7NItw|e_OxFOg8tXOunhyj9BEAyGF1z#q z<*4hrp!RPFw1JGxTPlO;=3>ucXg`Hz~E_U&8&dS@=^0{S>8fMz}Kc-v|pHD3h z;TtT@&E1l<egX)>1V_@eZs`y+$O+^Nw3&58dvO}Run?$p{5J)o^Wx?v1QX9&*_Cm?=k$5!hn z(GlEjfwC^O>~h{TR9?AUBvaV-OsEu#^>g-QIx{)iMwWX2pdF*KX)L_5HP z4M)6X(^4#=%~a4DOxF~O*YpP!M1Ijy6vP+OKwe^qS;_-^L};TqN?hyq2hG(7b$03} z2Wr%81!(LL;h-gECR!3nGhvq)!8+GM_SI`m{E|$}uEq!|V2L~Mm_buxYl5LeTQsFb$JW6EIm`BiDcubgm-AP{*D6z;|9 z{)nl9shS#j7(7A%IX#H}8d7BPADWV6%HWJ-bE){?^$$>I|IPrp>jDm8e2cbyY0Q-I zH9-I}^{U!PSAQrd@{UhY%W-brxa0tSJ zj78exZQPP!rm$E@5vB`CLI&C2!e1J@{vmgH!D2`89o&e1wN+SdMS(OhUj5O%z~#ug z427|I8N$;$+1o6g}*7W^~dPX@mieB_$AM{ze*5GH_+dnB{KAA|70$6KsL z@JHd@(coW+_mqPlf$ac)B-R5yF3Or8O{d-2!{AHccYt3Fei!&P;Oietr=8b$@R8{! zgFgxB2L49yGr&jU7lMB`_+{Xa1%DIxo50@=ek9fl{s^oO{4lH!{BZEkf*%k5Meysu zk9|Cy?0Jp_e+&4D;Kw5Uz^}*lfp5cpdOV%pD2U+eu%E#H3DyJteb`UnHzNJO$3E`> z{~qig@bATT{V`p*3hMzM_e^JiAA|1-{+LedZV~*g;5UKq0smp}WuzPU+przrkH)*g z!B4<;fIkiy>wnT|&od7E2BZ`CO?U_W|1dnU55u?%=RWoq!odDPT#;VbcgPR1++qAM z9>%Nok(O~h9{J@m9*58IJdFK}Z5qR2U&;NexNqY5k-sN#hkZ9)5Qx?@Ies^BcR&bb=9KM6zaS1{dhl~7!w7Z+bar1Kb^S1m<5NIs?nLqF0^&H}G-sL!k z=@^_C9V`DyxIjGdX*k2@n{ZbDIqv_P`~T20dOR1w%3Fr8dP=x|-EamkeiG}GF_Q!u z>sL-Y|$iv#?sflCo#G%o5cFeCDHb(U@0k~t8q##6}EDA z5d^;j;bdgCR#bs%!FF0` zx13!n$X4J2wcWZA25R1kK3lsB+p+2evtw328?;ZtXg`PC=TPQ=-?_s_vuq zu^L^J0G-lO zOwYw-w(=5n2bthFSZ|^aic<(LL!636aCA^Sh4DrSM=_Ct(V^18Dnyl?@c@ex0ksN^ z7hDvF7qJHTMCE#+Us~X~RJ#dTBbAn>HjBn&P!9I&g60va)Mr9ReGVS4#FdqF0cwS{ zLaifIAZXo=Yr$erK1+h*LWWpSeBkadgc2GbfF6jip|=M`*fR*HrjoAxus~hx)(x`Q zUV^JXN2&Q;oTKjcvi&?=2lQ&sZ?wP&abtdrMIFF1qKrOLp&ExM>~1IaB&m&!G1 z=EN($1a?i5T^1%M$Vn0`2t^IwT?lOeir?l7Pdz0&k~c_wmxp6kBBg??KW7 zamd}SpggYHiXsOraVWu5t{$#{ktqcWml~%7mnNI{`=pPKgkg06l9Q&mV8b6{|W&g2Ka;hX7Kt9KhF?Wg3Em>Rp<&@=i%JS zE=^}E)x%G+HOk2++1ls~f9`sct%)u^$<_*nr}&!TDWDCWE#nu)NbN?|Ozl%RV$^G5jf9@%U ze=~pH;bHmT;LrQHU;bMb{}zXLnEUTO#nv##`148bcX0mKcC~zz5Mwi z_l2jKuYa1Q8@M0K{W$K&b3cLmW4Uj7n&CBp`;&QmBKMQIKjUde*HrE=ewxv9#nX%~ z>z`(8vyJ?@;c2#pec)-f#@qWeTU#FE&%OLP;TiTk3;gleui#&faGqgnUHwN2b^hIjmKwq_m6;}dr?csaWnAFbTY@GjZS*5=}F250+jR{w7B@sP){ z-E55~{DnPF27d~+^Diua)n6E1JO0AfetZAI*4k(O!q(Pt&$6|6;IqCAoC&OL5l=H_%xQD31=x-GcDIPsBdHj;(ptqWr)zP-qhb zu7~s~;HHAR0o+6^2ZiPUfNLSV3EWz6>#Ivi1IP8|`s$*fFcN8!upVbYM=3@L>-jD) zULsRWcMq1Zc(&Qc!qwPkbho%uLc&kNIO+mM4%`(CNriNEcOLI0;|ZqXa)(QDGF-4< zE#Fg~!w)Wwd;AujAyEII$yz!?eQ+r2t8Gra3W#qr7qh#zDlAplK5Loj2T%ykGbkEC z>EDNjupalZFF~mFQVFVDqD1dB0cPexVg2$ID;5R%PV4Q^=fMFYAFp1c@i7L4^^S^4 zn+bWtWy2m{A0P(H6(~M1ZS|VqZWY#pas~^Ek{=>o|9qG1#{dlnP$@X$Ahf<@?)4?L zu-pW8w^iEku1DKl{N2$cA&>^@?nav!j2F*=y!FEft^cve3_^?Vg&Q(n#WGq0@L{XU z9n&?};FJ(}E_ST5)L3j{P~4okw+M8q#+0FLNFXw%`G{|Nxy9uQN&#w^CWoY@Ih~eT zD8L^V!r~iW*wyv}C(tP!;-RHQc09GY*io$YnWfTESzAFbmSOo%YfUQj*K}bHxQ%~J zr?b~KVJcYwnM&u`E9pA`2c1u!O6Q=9_*q{(`#w#W%AN3oWziZAvjw1*oDMFT=C?bv z@7v1h-Gwj!9{b^(msRdc!&eeQXk3=lY7Kr~U={OmbrKwxtH$Yg+#-!7)1454zRqP^ zIGGz6@JQ{iPEh}Z({m(kF6#9W_)pOOg-3+*Jxz|CAxsE2K!QPt!l^ktDjWbrL_v-y zhFKVH-$wE5hDae29sR>!lpzWo{Sz=B%Ns?Tz6P4?1s*SGfB&!hMN%dBSI4S?Hiy%5 zq#?{8==9iTMgs%=V@=048XySy41^!jA&Te_MR1}tN?r&nrBzNa$Du|LzffeVfp68~k$#zs(jpvf-a7JhW8kSPK6n;rBU0M-Ke^negy3 zp<@~Rs}>$vE_5u1e?J%gkSlcL!oOb#kE(cGLH`8ZG}4BIjnjoxBzkQMX}!W-Gnf{8 z_e|1ygiSYsc5{^=Y`%#!B4Nuc(pH77vq^gu>XKRe>Zc0pP~ZN^T+*HdHxhWOAPgIV zuM&0$#yf>&sJF(|2nF~~W4Kkw#16mADJ)0*b%ZFaz;!@;t*{F9*O8KthkEEJo3IG= z@6irnDeC75RYDH#qmNlF+>PzHe2uUf*8^8@s*Y9LX%W(pp4@Y zkcH#p>J7pI?CxvIgoW7k(S-2U&O`1enyD)h@X?(2F z>|FgOs`M`5cGU9|Hwr6pzkX`5KRO$VS^2*SAv$g@V+KwJ5tzXdn(js97J;6=eVTcDRts${)w z27BS4qMUsrdx60CKoFKrV=oYLrn8qX%Tm~jh2e~yQ~lLX-x!X z<~O6SowW4#2qO^g9(p)Iq~izf3N!T zqdUgk^7%vS!U@)y%D%S0+hboTojz`MQg~SA?Gt;C-kE=EYug9FjB!6r+GR+8_RpIx zzxw{vL)*TIIrK)uq!{0(wjC3nT7-+d)XP8oV8!p>dfl?>$zx9menm-+%8#m`w=QscSnK*J)^UyzDdA9uc^>Ocfimh+FYPDr) zrS1B=6UKg=qn|S4!yCSslvDicI}iMEROhYtPfjw7c2q99ymAauJ9YZCsZZ&1oL8Oy z$B`Y^z5MsprN2xv|KY^u3)UxdPu2YP52oF@=U;DsV&ab5vOm#-&}jSi+~I4M=S(*4 z`R3~p1ta3;pWM7=_Itk`v#NLOte*?-#sBr57i%8B%hI>!GP?yv<(c7EdN;J?o5t<9 zS-x?|ZwC4_euya!06Cb}f>C;^g{_!t! ziC1nteD+*d)A+4_tB79d(7&GEwq|PYLc{Pc#;$t!!sM=Vf1mew(wK+ueD9TeGi;l6 zmFMFg%D=o;dhDlyaQfL7=SWRI`NgPtf4}4A=RBwG{hc8zGrKJIk0-wS*)Jx1G~#So z@}!LCe*HyS{R?de!&ZK^tnBz7CRT6u-nj%gsN~yE-M4>je5zo_@aOA<(&w);y?yj= z?{BV{_xRZCJ%2w{_0R`%(?;!Ca`5n-4~#hT{`Q+wmwr6&iSv${>}x-LCK|acZd%?G zLyoSD{o|Y0TJrC8kF~d)yH~1KG7{fj@NnAiA`Ay^iG1l{W&hisIWN9xb-J@a(L$f(pJ^Ic2VJa#B0?c=2t6~?Q-PRajg{)m=|zk1&{xxx3#cWTUw zu6lavxCN$JPyO=9p$pqi{QcCDdz#Dg9$901)-&g_33q001`(Y$cG9h*D+``3I{RnO zHD7-C?H^t^^X2K$U%y(KQrGm{O}8ZOef?*ro*ZK+Irr`oyMN{|d;W&_7f+-fG+)25 z?6=eJIR3Y1{{4!rgtl(Zt*iU?nIn1s+GV=+x4i|wdLp{-Y2Dp_OuW^$^YyE)8~et- zKOgd(4x<-^P91ytwv}x&!7TQ~%Q0drigK-*{%-eQZqaA!l{_ z#^1mA&)Oy7JI?TJ*hf0wEi89zKJ(kpj+@Uv;wY+FHbZV#R!upz^3u!pWb`^`R-xAoaOpi>muJNzcaT{Mq}&?9b=V_~ozu^wCGUzIVPkS{GLPFrGEg{r16}ubw)& zFY&3lvu?_6cOBkP@pI$&EwB)Y5ka_sB;+l|Fgv>+5c4s3%E3x~AmY zf4_6*-0FurZ;0Pqy7*j5(pP)Gxqql}gl)%S*Dm9=>r+SQ=0rE$`PS7_AHV6#10QUA zZraQ9(-xn9uRKEDzgHLW@uq+MYWW+MwxT~D`{3SFpQr4#ly&WR^*3*gu>YgQJCztF zZiI5(it6M%hLm3A?=StLJZd z`iU3Y9A6m5zQ!ie)IYbT)*r9wO`QAw={p~|;j@1XDPHFMaLle}vTncry+bF%uJ8LW z>yUTCh)GW!9lvMH%lbE_ycN0UpzkjWPTn@;p@U&3|91bfPp8%8x81*MQLRV{P$0zjnuO|50W6<-|`~UzB3vuTH#j+{`54KPF$QF6iy?ZD8 zEAkhmzRE>o94EZLkbWYrz414df19>v`;C8_KY9QCwNFntg`2rTNm|Qqn*QZqTo_tXLChEf3 zrhh!ya{DcZ-g>67YTmeq-l>?X#3pPW?bu;kdphy{l7+J$**Uz!IAi6U(a-JZUO4I1 zf@?P=eIQE@Z?7|tc;Up1%i2p@8b5FWiy&_^#9^f7A$eXLE0h?Rthp+6BKhHV!jjK3EmjZX`a zajyxH!`~L7hIa^2mwhcnjfl`0MkMGABd6*NqtbOlMy=8f8C|6tlF+1!PIy=sJ?2GS z^yTm9qObTu7jwmsu$ZwE!(y&P`GN0H6_~N&CJDmB_vnPw`9`7cmk+@F`R@qf;BU+V zo%g}rfZ$brA$=8$_6!OIiH8fh4aP!EH)Qea23R*)$F08Lch4I^e=OsA<~m`WRwg*d z&G4_3-zco!B(jdIL@>zf_zge!)ivftdj{*sCWypsdraN z*U|TI8Q0k=ORf|C5C544{F|)(GyRBv3x&Av{ns$azt9}@LH~3=@?UWIp9K92`;q_D zvL@mG^j}^5PuvZS_uSj$*(TrTz5l1%fA;fV?AZCsUp?^a-~9H$hkp0_hadUFqmMoQ z$N$;&r$0aOa4{`I-%U)Zzv#g|@w<<-|-Z*FOAd*g5I`}Q9=_~u)GKlJwD zBS+tP_aE=Qf9&`NCr*BN>Z6X+AAfS@pPzoFbbjtT`>!v)?E32KZ_fR@yQla3g>Nr@ z*XI{>Vd46S$SA{*=$P1{!;EpmFB=g*a@6RAF_&L4_R6cqnXbNO{Dg_uPMUn(^-~h3 zCfyJ?Pkpm_&fHt(rC$12_3FYkYuBy6bo$+!HgDPb|8)ERf1dtn^V2gjvlc8|bn9)4 zvzIK*S++cPMP7cv%2l^B`d>Qz{}uHAe}x3p1^)fmUx)G-+sA9K!q2;bU|4La{W zg!|DvEhZ>!6fZl9pMwt@zDm#boD-rLE!1C?qlSk% z<_V0x%`rUYN(k$D_?JOpFND?iG4}_Be;O3t9u)prQ26IT;a>!Wp9>3wpAQPhavm7M zpRavCjr=_BKQLsz@FcjcPo|Q)@N$~4{FOAJv2nieOK|veLtaTYUw8yuO6+{$7vL5i zPow-_Zk{iwKYYgDqa5~q@3TDF;HU8L|F*dPf$35>c9@<2dH>I;f&yVfV+MtVBP{&% z>uEwf4{vRnFC_BtqWk6x89eO!8Qx}>4(o?B?RzlxoGvV-7_=~)84`~-4&{WzWs{BI zOtMG3Tv(;WFX1<V)-k(7v~EhGGdSzGEuu3=(**_G zS#Vw8&VlO%cM+WM4zvec9JmB<6Tl^dn*}Zd+(K}R!R3Hk0d6I@LU8NBm4GV)R}QWU zoCt0cxF&GhdHx5$Jq&IaxZU7h0rv*D!{APW>i~BKTo*X~yXo4(C=S92;7s5qgG&ZC z3tTF=h2U0#D+9L?+7u-qA2d)>K={+b9E?24)vK^Je3dt%ISWAQz zWs;EPv!D%_pL$+w-+aoy)!3$SCq?F?YaEMCJTMZq?Qjnui!Q- z)ffh^G9-?{b_T_$^#q=CHOr!bxLe5%80N)0A+T->%|B^mp^iP*Qa;wUTj>oqrc3lK zQZ8AL1A3BuK3LP!Jr4r6N~pqTcevDQ^*DoK)H;i4UnY&)LN3?pv~oNpx(h_m>Sk8* z1Pup~>^idZCMrG-?Rg2|6`VuGF*FWuZxUz@XFl35zk%%X-yqcT`b+q~M7pn<{WEDX zwfvs|+UG*yX0rcyGuctapOwzJt02KrVg2&fqkF{~)+s;JUyGCw}-`j(U%)NY-Jk|MA%z^=9?plQ}c~%W~6y zM7e;)f17YukiEKKIMe>Wg5wa1w0qMiko{ z8enkQ zm>-Lai$=A?tpnF;^kDL#lw?ZqZPx?gvfG{d!wto(gx~6eHav@H_x8Vo6Wl^qh@Ixa z+FK%2^H!_p-hy|@)w`GRhqb8ZBnGDwv*KLAEzOrxPe?-Lpi+U=rDCnt{okJdOCCTQ zw$@5>l1aj}Dw`{9N$w(A$HfWUzNXX4tY>C!B%MEw+mU4U1M%Quxz)wb;bGbWtC(RC zty%P)V^A50XPMDpW@vu2Pc~S7v`5CxP_Uw^-MJ{9N6ov)m5<*ZhGbIH7N=)hsl(E7QQY27Di(TQsd;U0_uYzpTJd)!1jE*;cX4LEnasMF({pFwE|$AE{ydgHPv*}v__MmNukPc6KUv+&hv&)aem=(Ip1#22)xCY|l;q@O z>|a_f;C&=?4oxU7b_sbavZl@w1oEfdxHvO!$)fb78ElP#-;S(5#l8tCImN!UC_BZz zjVM3Gz5x+WF}nbpPBA+R4X4<*9QU1K-|g=>#lHP`@D#I`@YpH#O~{j{*!TO-onqfu z>^;T4;dtc~`$nVr6#F(~|0(v(#Nkuy8;oP8*f$s*r`Wd|$|?5!e%C2xFQNAo`>tR3 zhNy-vt!z`FwY$bJBC@pu;2N9)~A}U+qD8bLfh4K=_0M=W@ zEMZ+)ZIuI{lvXhQDs}So+GKFkz*P$C08!K<*Hv0Kie%|b;J>b|e)@g6l}FU6eq4t3 z;;4>KZDr&zuh3*bBN1tWW2XSj526+(T#iZAmz_;UwOkK@U`ej)T% z=hs^7_n-Wq_g`!*z4I?a*>(c7WoT;8jp?xujOpI_9dAj;yl_TX_S3}+sn+>IUDL4B^HqFx6*#tU38K=U06bM7HTE83Cp$k6|^_D z@t^5JChZXj!hJ#E-9h1Zc^G$L&i)hOEf$a-7kNA~OWddF!tJDe3qk@9<2M?R&Y_&a#` zR+Kk9d^>&*$is+FFApOQi3$rZz_p7Kz@acGEJ8R-SWb2A;9(Wf(j{{s+SM0ZC4{7$6LQtH$FLE*DJjQt+>c{=dI3c_v9 z=jj9y%dY2PjIZNitoK14UW!c4!-$*0!#E+Dd@P)WH0NQQCCYdhX>}hDV_)y)VO-rE z=3&IGi-)mY@n=~WX)=R{v0qm5Ft$tNVWjzkJdFEXukdg_j)=4A6hhpDe+6h5`>#Mh z#`7@385sizC^rpD11A$xiBbCc~H3NUyOH=UJv{WOWsCx zKS}Yo3ybmF)S&QNLGU|*!sq^l{kNQG6#E5h+ii&V7l=U`wR0APZ{vNR6BJ(_6yNX# z?isG2evJ*nbJrJv@cu6XZI2J4!)Tn-_?^-=elvqGNF2LKhu!Go63Pb6Gj-5B5?_0< zb)pKN-B-Cmi(wEfLP<~_+`%a0zD@JlK8EF&!3QZl9_5<@sf@_fYB7T?FU4-4h^x{j@Sxoyw zxRyW&gXgjFHusOW^Bbxdz5~-Mg}VmDh26+0((N1;x{p(ZFfgyWQfGT@CH(d@w)=!P z1UUvNBax+?!Qx$)jK|tb`JF9;_g4S(fm|AzUQYLxu^ljW2hN58dDu-Q>i*RRey59t zuOTHqT2%O<>FnmkGJg9DTZ7vM%{Lexp?N~_u<=`<$_5$5Hhv$kZIF_cG&*YNMhTWa zSlm>?;|{7DN66rLD(M?F2d!`K!1rbRwrGiVC)H6`M5FF5+WQLhlZ)S15g0R0e)AYR z`DGa-PZ8}<+30q#!P41y$33jU(j2r~Q9*ZGSq8C?2#N+n2FWdFP<%>_#i3dG+_?+rV$)wDSHLj22~l51rkc=Nc5B3fhHG+b~!< z+Z%CSqR-U!J86_lgW|!Sm<$pwksc^tdx<<&+M5|XjorY7(njqy#~}G6f)J>se>%Ih z4R60{W>$7P`c$gH5|me!|5bErS1>j#Zt8%z0KKP%(7T-8 zFjzPpzHj~rPP>E8)|Qa?sozs(YDk%ZbI-K{<{oxSDZ2$6OEXP@jbdRydSL&S^{WV& z%%Xg0Lb{Nn#Z9Hyr93V+_VU=MxEoIDvpv$$&Gv7UZni(u)y?*6db`;^j?lyQl?**>Un#DK z?eiq`uze>}58Ef2+{5;pl6%-b(X1Y}-;~QrDAC@4TCO(rKS`drvyqr+5JT8<7s+l(#@Q?2q9$7972fxYoEJI zd3kw%zQ5n^`}ll*-~aFSSUu0$^Im)Hz1Lpn+;j84*wN!r04P9m_+m$YyRZZhxd2K5 z(c!)9iydu8SAMah&r;Q2?C5hr%@;e`POtr9N1qMqzu3`sK2dK+pHDUG?Py!ypk8@~ zH>tOy?RT?!<@j6F+tFtpvfhq9%k%5)=rfb7Uito0$9m;^FN4rKZ$JTDfma3e0aODF z15{ThXup95U;^-M0Of#f0W$$L0doMg)Cv0DbUVOJ!0P}O0O|r30qOyk0_p=+02%;R z1GYzB28Hqs)d|{1VFdUZ!i@pz0XqPye+7L2GypUKGzIJoXaU#-)eG1aPzGoU=nU8m z&>OHjU=Uyrz*s;tz*NAVfO7zQ0cHdC2FwFA2iytR2e1&ZFW_mwet>0w{Q;{02LRRp z4g{S?!%%v_1e6|7 zj?w{UqI7_pP&!yJ51@2_B`6)>B@_=>iQ-|wtU>XBbtoP!pp6g@r~{~C0qp_Q0<-|s z24tgfSTJQM9MBns1A3!y66Plg2aHAGfT<{)4f7L)17@Rez&sQV3+hh90~R7)4Dv!e zU>V|JL9IeOU=2chXeUDCXo6K$+ZjEh2Iz{OPqZi0Q(>M0b^&Aq`r~H~0-DeMXh;9$ zD|N)bCl!cosvsO6)O>!9#MpEWSEdW0y${NB96{Sd!5Eb`WO(x#O8NZ61YB+eZ7mSA zjYr@!4?){Jkr}ZExi{9 z(tdQM?=X8cmm}^le8&>#S(6u2MIwzyff_`eB9rcX70R>_(@Fqsw=r z%MYX5;7GV(eL?*lhIPh@e&$fAH&)n>tF(AMXy0uDo!5iTn@C@~(s{#ayVZ%dV-o2y zJ+Taf>Gq7qx@Cp^yTNoj9q95S>AIY0+a;0?cc%LrLd({Pt}BU_p%-oUgwanFD4!BS zdS^wyTmj`mHgnTFOl%&nD7rnv>2@a2UV|}o+meZ}=Iaz9vH9Ac2x-2KpxZEnZm04| zj={7%!|3)n(d~(%+dQ0Zv(n$792ez%_2G2iBj`2^qje^NZokt18&98zhSB9G(tYv# zM_&?%gyyjeAflV=jm7dAf#*ZhbwZ2q1gv8t@Z9*-=ExS;an0B9E%HXu>4($TfpnXP z(Dw&IiO6QzD(6vCj;8r!g+1|2^9q&XMe`GJFKuZF5wu6DN!C%cOqKUv{BfH`HqR?N z!m;_;EoD1`v3$Lo=antt&^%|9>6LRv86HhUx0o{twC*a$Oxa@B-{h{G1Il^ZQqSz@ zK1Sj+!OinTIo`I!h~LJtW%{Q6JJ6PYQ$HPuAy8 zC0h33pY2ojp{ees=Y*AYI}@Xu>sH>YQP%B@>x*u#Ls|C_JU8Q-WgLcOuWUyYF`-$? zO?B84W18z2PPnwF!>PFr<+umqwbiY;4ri?Q@wkrP$4FV9d#gST$L&?>yF+uiu5@?^ zPGi?Bw?CKb|Brrn&|@3iVhvH2YfrfRR#xjVP?puYjV{FS=CX$3>v(#NbHI7xn@6rG z&uGG-#hBQ(ke3V26a9}o%9*IlW7|T;N-a>P8A`Y`r}4l#7f@z{1NZ>2cUIBOq-8Tc=i+lipdFZ|);39PP z2fT{z!2;euXMVtI=sq6cHgwhpJc9091D-}_e84Jn&m0idq^W67TtfHR02Sy=4_JzQ z4#+2A3H)kg(E>)Gvpn{3_y8Y>eLKk46Q*fT@4*S0_VjOU$N>){p9l5<SXb4d~v#rak>#mugLWTF$Ql_ahet;129#K;xyZ zWl#U6h8+8pjDa7D`h$H-W?J_2%dsp0FC$+C;7-&(!1JiRTK4p7ihKZ%qV@uwLHz`a3+PfV+^71@Hm77p!ehzdlI@SdE?&13ZR&O4|1HcYA^WA0l}I z-bMH90q>*!1Kvi@1puDKJ}Bgy%G0)|E!ZsVd)ldOPp=-a*hf_ed>HB{_QjpnwkMpi zuL*q*qYU_XR1WshRRN!kd`f_u(S2jU-AJL^+0$!{4&Wi=Ljo+qJ|pC-G6#MRk^}Z> zk-$&KK0f;WxWJDSW=21K4nqPo@gpuu(Yb5fep*NT%13v-D z5Bu&i+S${VdLi~DW&!VteTeikV!$s$?EoB!#u57%4*>6m!m)3z1o$}YYeP@kUIIQD z`^?ZA#VUbM$G*t+*hfauudTADe+#A#&>j2e=qD_7?CBFp81@AI1NaQ=BSeo~`2oKM$xFwcJ^>}` z*wZJS3_wrRe(X!l0)9D?i;g|Ls^tT&K>Cb*tOtN!fYur8qbvb_8uqm!m%=6By-+;% z!B*d%y;+T~ zYft}1kG-xv{oOZLU3>a`?^JY-iJ`0?H{9g4g5(opY`mC<7gfOZb9*YD^Yu~Z`m36RcJoy zp%=0#eY5Bt$U(rTU>_>--Nx$K)4#Jq)GK|xa^U6IH;a6}nZT!^@y5RD96fvbJ;j>< zqmf=<-*o}-zS!rBzQb4qd@A-;BOi7t@Bv7U*vDM~ydU-zqv!mq_3Y{I8rB0&L3)CH z*arIc^vT*(-=02cTj<-i{CG`8tS5X}%67LYuF{iHXhE6Ns4R>j+{}^K~Q<(BgV>^L12< zdX;xdl-CnmgvYnK{*_m0$tkZV<2I+z?N6caDE+d}T82lqumJ<`z2nKvz5w*U=?sSM z;56B#0fhf=d6oVGWQ8>O3j&Fh=5tgi5zy>A2qGey?O~L^=^EYhYPv@Mo30~?@D|sJ zEzVd`^bBkAJ%kc*&9<07k=T44K%_KZqgMX94rsm(ZE+pm{7eTLzo_Qx=oaO~Hs3F_ zqED+$*T0@YY0{yl>q*Tvnm=x5Jifc$bR9@UwzzH?{wsY@i*R(;^4E2I^K~e0Pdt4` zI=cBfib!t0jwZsJuVZmL2D|C@YN=zo(B#-E64_Whv$O};Dizv&zv*!(<*74~*2pGloS#5Vg^LW$sJ zA4vqQR}n4dSd)(-oaUOIU5y~(ntdq|L};_`2>oyJDWU&Oeg&nzDt$rdf0GYHS*r5n z6os$jT7)b0^4E0~J;qV^n-dAmzBr|)R_Sk0j+ydW>B)`8b0((Q=b@Yru~-kHoBep` zf0KVAfQW0pM*n}E;REQBe(}LXU@JZr`$OaKJ@S}lAE44>9N(f|rH??Vf68lRJDZ+r zQ$EWQL0>Cscf|w>7P`fvwKh zaerKcmEq_qi$A4ThDZLx-_s1$uWTlZgH)ADlM$p=cpWS zrT-NDZ}RIY$3y8eMNft_`Bh_SSt~~~@gI9u%kY+cQ2I%ei0Eejrm|k;85qq)G&5WB z39b0(=IbybqWLI@5WWzFgqi>}k9ErY`0_cv>X+-N^t5H4~ zFE4qgd_q25iE=c3WmFtpux%28L-=rq;BLX)A%P%+4NkD&?m9t&O9<{B9D+LpcXtni z4;FNoVFn&|y?g)kkKR?gYjv-6dY@WVrvjMLnud1Mx93@n;@naQqAyf9{oqQOF0BNr z%;rsf<;4mH2M$cVn0pFL<7`X>{wgV30j2vsYyc_ZJq)JtN#5t(Qi2)QB>iYUm1rg* zGI6kHof#`EStX@Thn^2VQrx14<*;{|6?Uq^xfTsuQ%QNWB-(Q&jPsgFJ?DIB0os#b zN(;B}{Ih$g!nnvQ<^PKl8ctw+%9sC3fnFw>sU<^r`CZ@1TC&Ic_81*b( zq)X(Ut8g5B+##8lTgOad3am2qqwyc#SCMtP7GqSCBbHQ&bC?X2Wv3x9@3tHsywnG> zJzb{p1h#rBj>AT`K#F18I7_!K2KAY~Ko_bV8q4DAE&4%9rtu4QKrl@tsYNh$f>{I~ zilxNxs~l&>X`3)Vf|2Z9SClvf!l!eOsoskbPRPX3Mi}LCky+>`^IN}`oDPRG;u2-i zSG`t>GU<1VYR*Gk`Ah>Lmq}&rj@ZNjz#qK9OeN`$Z!c);ljl49b{~E=75-`5zcC}x z)aZOSZaQc8wq%Dyv(ri1TKZSmUFSXHzdLU8zQ}UP#THcB5C2Ig=q|!A1d(Oy*`CrK zm~RXHjUh_`*@7u%Md`sDjp6pi{sl;K@pu0?cZ(_s zQ#|Ar6DH?=n6{LejsMYJ+&1l#UcR)&K41|EV9NbvL0ThZ=7Y6K!u3kIW>8(3ECZRC#?jX6(Dj6B}ntiaJKzZXZWmDqaY#n z`%Bn5|15@rNMO-NH4n(q%rh127p@<;C9NQ*YSD@zr5t9v)L%;;xWec>KT`@{wK){Tgghd@8nIwflhua8#>2p zGa4l$UnFp1eB!2h)wVW|>bmXUfqCGjmRp6++`qeWk8$^QeS&ujz~YN>s)-F`$pyXE z^TB-yK5V@-4$ToX;ZNj@r)nIXs+lHBf|8-^a3}xbnvU;L}Q8Gy#aOz z;K+@F(g!#6Vf!nG{3QSqEjVGalkqX?r_z>k-6de-sJlKc@a>6%Xs4-KmxiQq%WuP~ zVyw}TC-~D0w8CYWx@PzyUG|w^xj~yo^J}b2*^m-L(4;XXZ0~h5b6{Gt{IM_}K-_O# zs4~l6eCMEj_aT^|zu!_ev@5HlDu~G-h3$=>$xFdjQP%p|@>8l23mG14yC;hskL}XI z=_q#xR^*<-vL~+jF4uRInc%H- zv1vE@x{%hP!!)^-5^oa|+BG-179?yuR+tzisz!Jir%6;jrF|vvx~*cBLRcVK1pRKJ zmd82JRlfercKHsUhA2fiy7>b*g8l*qQ2Hd?=N6j5=e%`)`lTm3an*1}C zaji%H1abBUlHW54XP9@|ay6IMF;m`#gVVVvg$VJ??M&;KJG`GJoHJ{(PaTt{TjNAR z8u2Q7{X_2)qZe8pO?7iz#VykoW#3#U+p1-IDFWtaAKX-NNa3`13tmUo-_zELEXidx z^NbCXJ&-@;G&}ak4m2o6q9OHLffU^A59m&J4IkjIrS}3%cVc4(b|p=#slm5K2nJVsO%Q&=b5@rk*xbT$z87`$7~yqBzq{uBoGE;R?^m} zvor1)Ye;!B76#ELetss5xvq^A+U5+uCkPUjFflE9ICEJNKYHSgf?nIDS84j9I!Z}| z;ogmHbB`@^>P=<^7n|3z+(!Nj_7`aXSPvd_Y<8xa6<#}XxiO>FT|MpIbeqE%6W+Aj zd0>d4Yt_4WT*ey)=Ginhfq(aVJf;`HTN&Oz&!2zQqUpg*fyonA$3*{!W-0Q+z)Cm7sXD9po?H&FYpsbmhZPLv6UW_7Y|FJCbI_JHE zP9D0IG~d&o)o)kg!!BgK7S$sUsCzbX`Mk^Kh^Q-+VJMjcU(dh;$sT1~9bZrX1Hm44 z+{@@-%fN^$wqX`BTFb!jE4pF(I6U6%5nnt@;^9uR%P(E=^#prdal(AtfAmAHItUI{ zc+)I72YiY5h~v`vO#6ND_fX@G_|hySBCj}y`N=daU%E~ihY3{}aZ>ioiV|mH$*jpR z^phlGlh)DXQKTq<% zNK@a{|7WB4KOgGfK_sBoYxV<<0s8{ugDw zIQ7~g*8gq64eHueU;>Arj7R#xDba5m%&_-MIzE-Yt3rvvcoBuy6I+4ZQQTKNLv{clxFOOxMIT1mj_zCK*K|Q2=p6y}jrGVq5WiN8IE#)ay z>1#+!ty-E@tkpFsZctFHPCGto@Q+L%Smegnh5uM>Ej>V*kt z2JcSX`bnCf8GTlXAnRFpJk(8^ur-$0CGCn&L)D^jM<09Nae8p8mlECW;da%`J{(0= zv8a`PT&QlAw4sL`QRhu&D3#uebss%`EYUc067S7YqDGSO372r$n+^W~t1VP)sw<;> z^`%En81~aJmZ5Qy9R>j6g8Ov3AYVgsI_3`NL-)NuO5bJ#OdKqh%mKUMs|9AlP9nc& z#o|>i_;~Yt2*%QP#J>hpc+o$=I|b>Y>eRfdJ{3pU4es#d#QG*C2S$3b11-J;?q{P` zURuuJo-B50AO(RE-)^Yg7G&!9av_rXb?z5oDAdZDSuko?)ChV>$8^~XNSJ$E1u<2(j z0oT=^F7Z`9z3IO3N15TS%@9v+=+q>>f-}b8{jLcVN)|(!m&v#4GvaNaK+nGdklwt> z;>rbP@w$SwW7&!(>{JKs9kKEI`S<;$L!{nJY!TE_;zBNxs~QRyKdKm#iTVz27<#=F z@0ITQ9r6i?t&>;uIPG2EO7S;YSQpU?HvZcjmU5MP-!Q?!7##x+N-@mt8~73AegYqO z`#qp{im=%~tMImX!iJd@Cl^-X7r_deEtx58aU{eEm0ax7II5D+_sWFXqOpIH+tDvDozbQ{dd{Y zE@aH-w|!x^(a8Wy6MMWFAhsmk+RpqywiaexXtHk}gK;%-`ay~UZzSIONRC+BkN<+x zZwtM-&MhLioSwZYbj}C80UFJA2B3?{cX%gCj+D8t{#2&y5Js$>*ybpfiQ;Exb#0T>!C#$n$eN*P4lVg$r1jj|ls_xzY>y){hy|paVMF zF^9F+lSQGcR~}T0Ju>-EPXSqNHRCCFgh|e`>YCDj8#H+w|cY zot{y2_iV7=$YMDK-A|k7Nr%CYsHtN6R&M`MXh?786mw%e zg>amWC)!Hv(`~0t$KbMMrnX23^!WL2Nr|()G&sN~pJQ%!TxQjZ>0(a%HP*|N3a^nW zHoRxVVF|WmT*uL1>(Vym)uQ`j zC>_!PByGGzkM1U%wJ{~EL65*;BoRN%8Oq)W0AT=*-sKWWqfgo-bZ(z$9HHFyytkrF z5n%;p$$^F^yC3KI7h)rG8xG06dZ3DZgxKA5`Y6XT)AUr(%@ILC~w zABgM*oe%WtXe09Gd`|6WP+sap6A{HV;NQT-gis6WKAGtm{MA z=QtBjr-#9aGt43utoH&{`FhE23^I12?mSVJwd1buL?rM|nUvPVsCr38>i$R1w2K4^ zz8hgR`hHV0cqRHGY#dP&%n}Cp#?yBHIp(;qCA?svd!&00oG;R*2P2YaaF@&)+cl6m zAwTg&;~efjw2M#`JHC6DAAd8||3b*`7KoORAM%1NxcO4NP$%y_oQMQdybxu_3~0NF zD6J9Y@Su)p48ItUi3AfQX!P%r9Yl*rygnLWjUBmi6A7k&F^(PD<$Gb~q&SN0ze;)u zjPuQi#-Epm?}|v^9AOW=c+-hUk&U3gXs>G-Dw=OEF(cf%XcM6S1MRsXM5qz~hhYOF z;6O`P38gh#s>}F2FNKKt1|kN`D`AUmi6{K7u!J zCI1p@JK;N@K^U*l`SbHV8(oy#$uVhF^t$PBm;8Rkf2CTdmGVi&d7@u>jKy{@5Kgb1 zs849$>oPch0_IU(?r);z0;Aq|f%81~tYiPh^&BJQ!z;#URpj%xjE>e5r)jIyC6c!N zgbF|~AGm~Zg{(w+CGxm)=9WXOj$q6wI$g_Q?nE$?!|ImdkKrKY$>*5@p-dM+&ig!b~Tt%Ti}@=&}nz(G4D8wPim{=QN=6en>lF;e6re(kB~oDnqX; zaWNkJ;B}__lGg@iG*&+HM?m^A>MO+#S$?`>MLKGiT8_GOJ%lf7n^yF6*&vf7ID4^W z)KOuVT}*00=E=n0UCw!jT>2OuhpS3avIM!FSf(8E3>t38*TU@`Eptxx z15TvsoztY5mOV5MG}zjH(0pGPr_E7@T` zibX*bo5nrt69h3eVqLSYh=OKozG$J-`-6k3o3a1fJA-J1^q_n4&z8kKQMYEVot*;Z zWUh`;!HU^I0B@5|06;Tc&vAYEQw>FsTt4K;u!;}a&$8I_h@yI1@k8S*IRNG4Dd>`wzkoSG ziz6{Fbb4hlOx*x&u~SpLv3YkCBo`=DAz1KvJz$5XC7gSwE!H8xKFYSvG~S43@TP;L zsqFdzIInMEGx(TIL8jHQ0Y)>+mVay<_POCN*@oHs=3UAr0==OG@{khSj*+fNy=LjhT`=}y&%NY<(&Ata0L8y*4!&9i%*1aF?#Dm(q|Hh^SEJCbQ19aFmR{KN z8kd;Jpa7Me@`D2QK@1&tHG;qFHB`7B`Ht_`?~}a>;^xC0&=U&oe5w>p(N(5Du6gMw zl?hYGc$ORIE}08@W#lY}+QzNd zT9F?D4~Woqk}Ewq$J);7Q@Wy)FtB0wI!n2=L$URPU|x%9OAGr1dp}}G=3(})PcQy( z6Q)IGs3w|8*E^6x=2OZoqb2S9q*I7I3-{MQNdZ-lua~vaOe&^$YNa~~Yt++S2scvg zGx|3cZqT;!Y+9I1Dril7B#fY+(61uTr#@GN{Tx*NntlRnxBb1D(m}NCvw_aBze7(? zU8PoGly4COvYOvpJ67;6&XJh(i&UCN#F`syl{X0ba6e8eJP8)L`#K57d}cK&)KWZs zCMI3ENVzW#9+~ke-pCjEP$%Ae!qS=ODBK;))J`Z_{<^b?MIEkm3Kh`krCH?VFOEb{ zSh#U{D*s8d_}8I!t_bxb?LLqTce0#xZ@Ja3EdkE6V>u*TJe_ZH+!V}4UHAJPf3bb5 zdykSy$q014fQlb7t=pKIg=ZI3NvAqho*U3gMlxBl;S&#VP_n)g^WQJTpX#;Fx+{s< z9%XS6x{MlMFn`(2O2H}YB`*_hRh_4jpe4gob*h1oZ-~-s(@jL4&VJYS)@6mO=2KzU zcwWlEJ=az&$-<3zQr%iC`@_74`{j0`IpTowEy7gCByqoCEFh^@BiMv&ITq_Te@Ejl z1<`y(gYI&L)Uqv_&O+a!+>#Q~p&Xw^`+1!vn$h#p=5E#{)eo_k#$q4}>*egq)AUZT zxs-|7ugQAhp|4((n#c5}!9&dL!l@z_kXLu#KRzD}d&9abo`&4I=G;cjm}zay*0LeL z((EKZ%gtPhp!et4H#$sx7ZIJu^BemzpJVZf%*7_IaK%PmS=xi0)2rvu4pY*!P~G5g zaUflHLn%<<`aO}n>SRUc+21a2mJ^t%Wc6^4eV_!Bd>1OL?%?Mxb5QxwfX;xKkOyZb zxS>+hQ!SavYP!}02G_Yw)VeZRe9ejK3B?BJSTJuH6y^ZWC2|4sJ-T#!NkE_iYbB4% z|LUSn#)*X@yaxZB#w5hZ4P`Ci#s2^$%vS0z0^EjmhJqF_J$sWybayuZfD$Ijtpny< zsk`BVgqUKOm|5Lhx@X+Jp0!n3e1G@4xti(G9fhtgzvi$|gIOnVt}z;TW!rE?mT#_i zLUhIHWlf8#jyow3ku&z&P!7+-d#~f7O-&Rr6Lgd;e*EyMHNaEzxBR1PzRR!E(}1Jg zdW(mj#(+VpeOkKO9RL}}!v=Z05cw#r;^*GZQT;xV+|{}JB0lgV5Jtg4Lbb8wd<*rz z)vdj*lpT&qR3&w8I-^s#8iP{A6CErIe~pulMgSCAAA#{ivH?1tGZCGfon}4ANMs|Yw(h#Vr?BYW;j%Tp^M`bFl>c=>_e=ka!#PC2@QGam+5|ki zGlX533UK9@YLvAC<8B4Wo<&1yXAqgH0g3^|15WDJfil;t&}!v?p$f1tUO&$q%z9>r zoHfGfs8@}cFL9v`u1m&h#ljg}zA<}!TWMfbpx<)9+^WiANPE9LNXE+D>etRc6Gp?+ zhdnMcCw#Tm+8t1XU*}usQlObF)JA9eu`FM=a29AhyWn43PYjcfo}}!7$9#w8dhv+Q zT~Bqam6!dXN}qZ!J`Yd#a>&iIh=p;U(#rP~-+$H9@{g%#)ORK&nR}WtXr(=7_y1+s z7+)8xc!hFNy`ZZ@JD!mx6`61uoIbCMb0SEmV#F@jWx9kyJyiV~PyNg^6w#+3zvM}c zTH(+l+m>+XaB(*as5a`36?Np!?K$fy%C#ujIdMp(@O;FR_~%r1{ClaoXp3wg#Fz#Y zw&%EQ)%DPAutQS+(=4nJ5ptM`#RcrFwud127{bI7J8HtEhxGPrT8k*l-4sdZU z7`d6<^D@LTK$`E++MHZvLBr>@JgxTO;eJwz$g5zujJ6vD7J*)md;4%3BxIt^ce6R$ zPdw)NY@dHOtFzhBa^sj@^UKq3M%E~~suLG$LG~#))QrEorr&R>y~K_A_6B9g-oHt= z`gXj%%p-2v>_Ki$sjy+EMeP&8)>B)0j}y@h$_mK8CB4?=Pn8nxkh{8>S~B4+qAIvq zU7oLg^WSNhyVm6Qm%RqOhVi>X_G`hXi56F{?TZhH^fwGCn2&SxO-7}v;Mqq&O~ z9JH+kiD_y$*G@y7MFNAMGHOZCzD;u=L5;N0wi-}dq&s8K?4?4H)_6l+jjv?er(bH~ zDGH=hrKx*HBeL6kPHp&9aT%mbe2$O#C}$^ zCvwo5ghL!s|xXFwZR%VjWosfK~5$PYRKr1^CO45n6OZ6gVm z@BO*}humn*=?9L^-TOk{Q4KzJ+anCle4dv84rtvyu&!UhB#DzdWff%w8%I}P;fde? z3!X>FWs}6qEM$t9NV6?cy4#W?XmYELe7##_a~kr#ioWM|>-KW!yZMB($$iNkORwHl z&8o*iL}RJwEn?Jn=b>4I&Y>l%I-+*x5#_FVb$_qH+7D=l6nGZt?(x;T+x(MFdva|C z(g|eCLlhYMXujjx$@PlQkDX~&v-Oz>quKebv3LX~&2{Q9JJXmbgC~Qv-NqL}|5U-6 zK%$QxmL^vz24)jAUyEPnR6Jno0wt{UTtt`hoXzgiPiSLXKR0K2KB7)doD$QE|Uz@rXL8|>qV>icA5>q^YNxfy^hTkcJ|5niM59pt>c;J`>- zEe(w(L;6e9sjQ+=_P;*wWnto8X|q4(K|9PR`l{4D#%-0o&-V4bfj>P{t+@o%J*pq$ zs-qS8%am6zS zDwb9es!@Gi(TXR3j3A!5CY^cky$|(t;u^ZL$zT-cCOMr!Qk(?+PMg24qpC@QuulE4n8AEx8qR3Hlmyp+$dILyRSd+ zfw&l&@};@^&)>=GBGtIjpL;0V+DAgl7s65^4dv_h8yC7Qmle&M&?=&^G?IAZwsrxV z$p96Hkqrhys}nFALs<;=-nMr1jWc!qm-Yzr->IMZf(HJGUrc#M?@dhwz3R0S@fNeE*47qUN@HQKqvzNVcse=5?eYs) zdNCDTkH)oe2pOl!Dvo;zK8--0JHT)GL3GGH_L|D_m8YBG-?B)&4^o=iUKO7UZz_u- zp#g!0jrS-Ac5MzC@w=RE%je?O3)s?{l%4-v^z1f>nzaKOtPh*=uB`i`oXvBkpH}Xe(Gw;v5JeU!%!Wr*Z2grL`&`%-H!y(}cp_IO}C> zPamCVrlyY9Tq+)r9ZWqoT~b;kVag9(cbR6MZrMLty}-(lT~(tY)!pl&A1d+XTbZ;K z^mnOX)QM%1AOmPhkk6?yWRB%Idy^$vQR60F-5?kKF&8480NiP7DJR{S^_xH@F4i|U zpEqAj5@wJ4REh^aehflD?wWnpVv1fboB7-OR2TnD|M6>=c7(KM)TbL64NyM#d><5e zX>6FZWDJs?!uML#g^Ay#EG2+n z_dzwqG>H^JY7QXpB&X3OTpzLEqHS(T8YzA|0H9-nqAui2V^RHT+j)rXnjXot$L&Y~ zi#EKwo$B2DjjHGar`~@T9r94RrNfz6L-Z`tTGa2_#{%(d)wQJMRwac(UAtDi(FgI! zt9K`jxuqn0Z@YSqqydy)zn6JYGn0tlZ!Xat!dUD#`9)z~6ZZ9by7Nhq6!{f&&~CjA zDMLen1#NxaDFlBr;+x}oG`VW*q`HX(Ud!WWzN6YtMB-mb?oJLer|}*>*MC-?Sr%*R zU=7ap_-?aq!~PLI6fC@>5ojPFY2~zSGtO_DkN>z0p&!cij*ei;XUp}DFEsVoi$U|k zB)JUum$@#bOvTKZz&buk1M{-qvw<{jS4+XcqKcS3bq)jpfrWa%CNl*Y#cEjToErDo zA=peE_*8!4(pATSGoi*&_NexW*k9$;)QOWzT9n*lHIH+*0_Ep1TYFSQF-9WUeR)!(s;#rgh{f?pJM z{!}kADJlVTfYayN8e10L<;RCzzie09x5GMdD$07yTGle$Jk;m1neMAxBA2w4rGeEi zd)-=F>(Q=_qr)Zb{;&#Kjjwe?J5!Wj9zAU&mwZ+^>!3VovHa(F-d3hHWi8UdeN-TZ zmMHOxf$^=&(~42zL}4Ckn1q(@{~o0-Ifeqgt*@>gh7=8& zDHlldpHzrJ8Tz~;g1VcX(7ovpL9+_mBbzbO$>gCvjS)4>IhYckOGbhRMR!w_wczcY z{WWECn33R8q%k+migJg>)D7j`?AN}nV_AooE@-ln0}H5l8ZED#_Z)0r4<50aiE7?q z9Zux&KkBuP)|N5_D&)YGQWd3o|ClzE+pgU4ajYx_7SD6XTbh~n%2B!7%09RF@37sl zxF2aifuuo~f3?~|?sDw|IKrrS@K^~Ff;-+j-}*q@7UwMSm=8&C$)Az(^S75Nu4N+v zq?dz5g-*-KsM)>R%Sg%FTH$K04sQ#6J*etVMO?EUoApo6dOfI-rAF|B`p|)rCTVar z*SJ@nXGlm$#RSGIGX%Uh1Js z&3my3ZG2n#LchtQNJQe)Bz0{U4N|)hdmU>Z4R$FT_G<~4l^Lq}}3_LW%mFxeV+@~{|#?z1toJ&XS((5S;Fut z5BsuVEFC=DSz}#Q_l_HQF@C)4Imfvi_u>WD^+$KKi=UF$_W?d>Q9(cr^BUU+*?8)& zV0XV8aM9DLG&$bIW3r)h&d=q`*_f9GO~lc1t#pNAddt{RZ7yBjZs5h^XiEO#0IC?N z|CT;|Pja)}K%Sa(MJY*puK!Ujmp^UC?D!Q#gWUu8h(eowNODKH_uO*Yny=GWsMZLX ze%Z~4Q1nr*mL)Y9l~=8H1y|@F{wFCk)|`~p+A|N?y-T&&18G8Z#_8D0NLkUT7CH%UR{G~*F{ran3)nEJ68DnVlhXv;=FStlFKOy|z zC4C23wPS*w1zMb@p6|s~2Wdgl75m&v;b^yY@3|UYa#TSJBHiT4EA13(W@TMa%KI-} zr*w8E7j+s{>(6^0DJ3Qjj_};l^%M4~^1(WwUXEbiMAD~uIJzCz66e+A-_JktFSXs{ z8@p7zT(?V2|uzT73iV?iHmud3%FsJbOqhNwu*(#L2|&s250PX~2_3a0zfC@n*avAft(5b1N2JB`(p7ry)i?%aS>SPq6?>tM?Hvi+Evr>Y*-mU6}l@v$FSMz1WJ;e(s4a*m*100 zP)knw?x|@&x}?=>*jIw`nI^Zj5RXOjWiYQQ@v+%<>JMBUw&q&snE}gm)~6^#9_nt@ z(#5c@s#81(G{00y5cJEfAH>efGNv+9wCQO52My1(_SZvN`a69pOgioCUN-m}!JxB0 z4B}?>?|U~g2L=_{g9Cne_);&DJ__Eg<;3b3o4VI+$G_*vyqgl!MeUOs z)PKEVVs}1jJnM_RWmHH@dWPPoS9nkO@bpYv(9_`rpOJN6d?2Rvt15>Q+2-#E2!9&k zSWGTmrtM$hEkIW4rd8EyHtv*J2l=C38LWNUmaNrM&?`%?yKnjqzr6oZ*QN5E>-nFx zeIUpGDt;HmFu{TKPbN3$JIY-V* zVruOJ)>#CqyqOH>rp}~iOJ8YhrR%wRE7YjC+Q)%2Kx+;Q+0r!p zJ8D_qWsM?P+QjAib#YBn#vH||a?|j>dJW(GRkNOccb%Y~x90982bSiO@k|DWy|59%mrAN+JyyUOb{N|M3o&4M~YIS<|Z|$OY z{O7ak#!nE3YRhb%$oaORDe)g*`-bocA&3?I;L7OX=$;~g^``u9PZo+<;)K9vIUAM-;%!{Q_$3S{_616+90AP!q&B!<$!BA3 zmAa=$cMcFoE5>m6t)ry#hrhX!pm?LX-Z-O)z+-LH6D_0g!eHw+`Hx0^y3a-06}_n| zuTHp;dZxtx>OdTIyJq8^w?=;LKc*L}yv(>ljslSE98fd(`UhILPFUbTmtmsYhku!- z&s4Dx5c%HkopBvAD-EKT_i|wRrl)_RM>Ow2UR1F+)iP!{Sm!jD0>woL0+d!GeQM8V z%^#2o^?j=oL^Rxnrc<3uQUB^#WLBf#Befv-?SDqVcviIeC2fWXhWSWO8J2GsC_(5S z_%sADuX=_h7T2_wJ;!uHORl2^GNDp&4(RuvkgUdr0O{&f%}DUsVK! z!%zg?j$+!9YrdQHC`gwXQ)eF}dY@g$9xgdc^fE2+yUfV9yjPOw*B-ibav{?RT6EJW z-Q-nri*H2X75@!{B)l#EPf8Wk^U+MN!A9HH5%{oS~^Wl{fl~Y9x=rBFV^vRgZWOC|SV9tsc;K@jT z!w?F`)`?b>QPI^nP#I1#JC;4`U7%9XDZ>-8{pw^(i)kGN_09KJTdykL z`s3W$cKtW}t~PcVKOFt7#+uB3*>jioj!efp)E)I8sHNGT;sn#(V+p4pg(nEi^mNwv_-zofqAg^d!Y<@_z425 zI7oh4&6;gsmLm!i4}%{`9AT=hI@rRE*Nv6&(U$m7VI>=3M27Vl$%UiK8Ogh6X6|X! zU{qFoI%-FJsIro@QAt~S>)>!>RCc3;>C^JAc!J}_OE%r7K-Z7Pvx5MGgS4ghUz|OX=#PNneANp zFjB>_L6rWVZ7mUb(h|EP&epH9G;QVH5f$V{llk4}KgmD%Q9kPeV%^Bg=C1Twg z`sj3uAIi+WQ(>Ba?d_jEb&LLt(fCXp@rw?CX0H1`cOs*60X6f5Kvlx#|I$7SoU)rJ z>oPpXN%w=x(Vlc_fv2C} zWUZxeOxLxwpWnFTgc!FJ_iT>Utn7b!zqYA{8J`Zq?l(idy%l)kOLKK? z4K|+na4GpMU7tB*rO;PKVt;!+ZB-?Pfb?O@f|HYcmt(aV0(~!_@uq60Wzd zQWCDAD3QmkJJgp&#eqNsEA@dmILPbFuvv9k-QcB}`~Cn?>U^6-$pPB#83)>_cB3uc zHnL12Q(%)MtE~;zvl5(qDRY-^d=T4s2$@^N5|}s zy#V0a^VT=%`b`cB(Ad_Ho|5l6`$59(51fa9?q>bo)B0e-4wZK3pY!98)AO>bGaoKz zpkxBeZz@enGw!r2laEd(-##Iqi~1C4cy{k^9anM12L#VG4Pl<<1*XoIScg6P3~p^Jbh>&xx(m_ z)E)*(q~b&K$Fl~6k!jAegw`F$BOmC=KPn;MDbh)T6Lpx`QtTO!u(RxyFH>I*GG`gv znm2WxN^=u0Mx$(ep1Sj6FUF%veT)(foPWDdUWb9=n@{>d30}*^ARf}fhoI93l-{8g z1c}*|QP)6Z?+6re3w?;{=pD&N;$2ld7b0AR*Et=wHLgLiW_!*HU1dTikI6rzPsQgC zq)$$I078QY03lGSN6$;l3%x_xuyurPqUfICh#8zKxcGVJ#sRe@fd|PItUJ4N)A@D- zp*t-d^F(9jf0CYKG){T)+heu2j;A*<9>HT5-Mc8=3%)#Oc$V*a&h1USyx0IG)*)2` zgKBW+G#{ae)SYLV=fd5~EAMAIsj}TmWN(T6BM><{+k$j>S`z~$`YbdftAc6IAE5Bw zA0QsH{>Ol>ia+;e2mI?BX&-N$sm&gYo|^eUJkp$y%PZ2drvM}d9^)|z4`XR?Wyk@jf~T`xDfc8ps?$zkPHCiHj_LEXK6F6I4lBvWaKfj zi0S-4bJ`19azcB` zw<&ljxGAqw-V(jp&n>9dj^Ptm&mBcs$MvKrCphNPMET*@B}Vu5`VAI}3i=BLdK2C; zqb9K?^^bQcE7Z0-R$dkq`?x6XC=@s^sc&p6jFNY^ulH{;%5loymcL>pWhE>pyEc36 z|5gXPB=1ebi;2RU=n$-su5T70hp!Lb9H5-Yir0RlstmCY(Mc{)FX)lcde2kvw%~Jt zaY3#OOGxc&$=8*r^O+^;`mb=&C{T!S8PN?~$IGIP<=K!Fzq(b2)*!;Ae7{bH;iezwWPeM@~W!p$Z!O((_|<0{{! z=Gf+B@$sYU=HIq284V0n5{|~X`k%#6^v{Yle-^y&%s95yXD_h-h0C|O6rYe6u7_pw z{O~riO+?=|%CKpEbx2`|nfdKV{J%swuIoitm(++lG&@%ws-I3#%J#PBZtw7-ZW6QO z-bAgG>p;WbD?c9>e6*e8Ft#W9H@!PITOp&%iJLf9->1L~{^F(*hokp3p%JkhAPcYpu2`T9#Z{;37jYsAIl;N5E2{_Ioi=jJBUk7O*z*D9|N zgc7CN5bhXXH<5Tfsk{nk8s?d1_$4aZSLDOaYRE>y+DXe!sVEeQjp45N?WN78XkF1-Dr;A({-YgH-Z zi9?MJ+tmEztKP5c8~A3PM0&RS%d?2#><@~0OiZKaKT(6LX0oy>NJp&q5M8A~#&2jL zEeerOSjISQNO8x%_d+1~zLBGUNvm}N>sZ=rir zuH|%ci1X9#Y}Wptzxo2Xe>d+PXx=+9Lj=0|_Xr4(4CY0Q1!MAo&RB(|v5d$K1o|iO zZ@6z9eo%i_)g1chb1<*?A9OJ%44EAIj4!1&#YPc|U>ML2PW1f9>|e2pu8hWlV93T@ zB*4&1a`-G#8Gnp*Vt5UQOf}9h{IXLau(qG}{-VxyM3eGF(`+_Yc!y&M3NyVjK6!G{ z^N1O7Q24z0MRRnp?F&$UUIsjfi#v17+c6wYnv@FOa zGe}WBCMEJ7KZUopA|idfCB!bBtZLJvXwj~|6K&*s0UHUf5jEUdDogvnK3jYU zJ{mte#+&FSl51}tv^%eG3~+LXkz9$*Eu+3xRK>Ftz0jQOMg8uaXZN51ZsMqKS7mz9 zDV=XH{t^C)z($luRPoB=NR1EG52(h$J3?w&LUP9=mH*s+YefjxO=5#qc*HcSBLb>S zAO|j;DY*FMXt^=ELR=|5dKbSpvX>1HGUN>oxCGxku$3$M&*AD}<*uSyqP+YWwIT=@ z4Uc9GztB>F@x%@tkF4-)#E1o^Qj(Q3d1*vSUw-}`jEHP+XREVs7vi>U z9DWkk@1rtgCdRq++1eFre8>#%{XADOL?Emi&BdR@ylt-wlSd2$D1^#20yN{f-wn() z^LIQQ-J>&+EIgj&7qyjt=c4aI?DZp2G;7iUcKV+*V=?J}I*URmWHC~NWE;sc#@$1W zUICAdRyO>hYA5Zv8@2X_hX?!M8$2KM1R@Av*VPkl30Gu5l6uIcKU znp)HAo^C`GK}y2WLeXMkL^H95G%qP(Ok23;VC3Mlkbb$*k$S45`U^w=Pw}ysJ~>Jr z=@%qw{1vP%%5n_{SxuNjIr0RY@nZ7)$@tGO91Sqd5x%74XoY=hKd6d58>!Htv>1UF zYX(}bLw61I*2=;0o3~l6110vHPqZHlX+^K z;=Dl0$Ax5;?kB(%KN*0oHF*Z|-UB@BJ9w$H^v@hdSvk( zVjb9@dqe&X7O4$?3$lL;{6*&@wkQ_ZbP)v!0EPv86m#v}^Ovpq&WGo*N zv>XQETo9MmB6Tbqq~+F?oTDpy8r(G?`|SE=O;cJ^xovB>vv%k1R9`2zR%`n(vEUG5l;U$a@lTTM%&dddEJ|e4hGL z)r$>lp|3Fw@ea~_ACP@$OCWmZI)ps0i(DtyHIr7;6W2jO$W0)>`5B0SizLZ7{s}Puri^1By9m%<6dDkgxDLVap|X$|fH4?q zovw3mJcni8x4;JYTS)lt|6IP^e!2Hs4@ytCHii7zf!&6J{M!+2rGS;5ptMyT2cF&B zx5Oy^k|vhwM1W}iurcjFSlL<9YOW09DAO3dfRIy}9B2HwDPP&vpA`@_^8e-`hbpyc z{F$ou6DiCZN;ApHq=Z%PJTzy>FcciLG)(&^GM3R8($b`ua zZ6NNNjMrQGO}%KieFcQ7o?i$>6ya)P@6qK_#?#ym1!heTO}O?7PP>}*)th`{XFMwK zIFfY-(VT5pqD-*%{fdrZ87==;*3bUKUv$qwZP#s8g(O$^bz?;)xftK)`lIA12^8XK zB~G>-@}i%BesQceUW0?#W($v5%bw?q2#{7LSMKS2o`eKVkMLiNg`bPz zHf(`_cSJ$W^39xb0h{E22tN;pjF5zgWSeA_V~v8avG8(&pO2nzjtToM{r)b&J6a_= z3NilNCXK)e5ZN`86iD%&2k7l{6<_pyoA=goFTfZ<-RF z5--Q{sKc5mWRu=4armQh#91hHlC#IUEfJ|Q6Q^{C_$EJF=UC_1Y?yyz$7jFG)XC&l zGD^d;$DByS9g4PLHlMOIu?#XZa2s~Bc6H;M`EBx>)aR%xtV{0|_toIFr=NP)Vb{H& zOpYNQixs;L{~9-2k#G*@RQgoM7Qv3pGp$30p7+->RIZ%8x)M5t#pvk);jKu;)X7A> zC8~9f>4xdO#TsV>XLsgZ2LB}Yn-jUs{-TK|Ozmf+EK)8Bi>sY?hozN4Ie^kc-P$NyF+=oZ?lkD2=QtdMC(wQM7 zevf3vjs^@i4LQ<~C0UNCjG2wuj-icl$|IaI*hbwar~LA$U|A3w2Rx)oouiAR(#(%> zAMhrP5lsIcP_yx}=E#IoQr7lf(p=J7lGKI;(}G#RXnvT-pN`>VhtUqVc6tt$T!Y;F zKukVDd4QSZ^#1I=_36&xrai^ys^^4%-2RRsnZNRbaUx->U~xam?&?zb-fUv=^kd4v zS}<4;TcF1ts2o8b$y*cN@ib*U$Loz;j5eBDHEK7sHX-Xq=#A*5e>9Gn(S4}SXF_X5 z?-X87a-1O>CmTbz83~CkYvf$HTTxt%ShaF+b|UvC^TzdN^k#kHdqUUw<9_gbL+LZt zMbl-s#koanES#|8eSq>T`H$+K$SYJJpJ+(_xJf_jfeF;=*53Ah?{OQ}J?1^W3ta0b z{E!IK>OUr!%o&WMSk<@=co$eCfE>&e(jC-tmKyzk!-l*?`CfE#7Y1DG9a{f3to1tT5?7uPG6iWSaCQ)?PgyoxW`0_ znfYULmf679t^+)o5SY!npHN2eU@T%^a;rE$MaB_1eU4;V;M>_(z|hJL7Bm*>^Hr znfT}V4ET8Y1U5trF`NjVaCE*erE?LLDfe7)gAT1MP(y~!rsA6h^a22!_RvH%hNGxEFYlmC1Qdn&B%{iIR;* zq3h%0$d?ndM+YpieA2qawUcM$xPK5^;8=X3`oW&b9?_8E8t$44iY@5pvOu@I&7jKI z&!Dd_)MD#pZ!wbwG%YpqG~ViU>Vi$taT-+WryE%s$90|cIxbi)Y%d5e_%GltXfALs z3?F?T@gC8=put37DloA@>U{is=6o_|@W5Uqe_+mM6=WNDy5y$lrrf4{jY0k?{+WuM zjYFZMuDt~}5%-1mHAAW^v0Bb$T$#D-^MQwb_`XOPH;ZB8cPi|X2#@sXU*OM5#bRWwHb+_Bdc~K%!)q_@h zO-Tw!^qxt^=^|-8X^#b8i%2Zf5YJx?-H?2K3dwF!R*g-glFcXZr}Ag}M6SjS*?h0p zo$Za7tpnxA=X7saY;bN=ZLDvMZ1C21RNbtB1a5q9UhZ^-kem@`!g$H_u}2gSr}nHs z+8`c~R~T*1fzjS(cv|;Cz;4Bj7A&0LcvSYJKB7Qt4>R@L@H9ivH3N=Zsj%7~UW94s^WljUdaPp71-WIXv9 zg^6PCVpTKGH+6T=8kQlcDRPTMLA9q4ib(d9b3Ba zxW&6{xp=f|8-9qEbXj>_F#6@Tb^{Nm_iK(EgSorHb%y+Az(|4#=)DL*pG3ogaF*O# z#Zc|{8n+zO=* zbn9t82CPe7A6PS_{jI!|pA83YE88)n$(Hm!->b>5|NILIHEIA06oizZq>c2P0uc&5T+I9K7~2Dm*%;M&K~>9Ut1Py2UB}gc6{bhYc%(Lj zW@a{O!QT!yRh9IYLOCOwqG%y| zxmHiMM0Rti(NZ?*54|iI>m=e)$>LBFcz2Ia7iHalkSi>k_!#*vzo9&bhal&U5Ls&> zs;1VV@XV@-Lwz057mxa-exv3g?WXLe;f5gdg0*7BN^@Rv5_5%guUnSfH0pC8p$kY) zt{TOPh0;c}|HPPuivV^*wkL`iJ97JOD!Ar`;2z;7>#}Kz>#^?#8o3z>69#;0;?CMu z#w16@Qo)o#+5dB-481<6&oYA*)BXiSwt8&L-CRK}qsDoRG6CfLY@u%Fp%d*R6fm1V6UkSyU zLl^7K?7GncuD)Ndp;~>3YNes4L335c!JX&hw;cW{%Nd&{e&`&e`n3EoJ$)FSYt>qo zIyz^lnY31_B2&2h;cV!;N0G0EIilIUNp(9WN1`x_yD0g={3|Hd#sVkbYRQwgx_iTf z7j?{;326vfOXvFL{?3WnR^H=O&&17nrG&ec;i*rQmwiQh)F|kU zIED4n;8~5Qu>8ly!XeNXC=|{grZ*-qdUR*#Q_^LZ$193gt@nvbQ1M2KfLtM!M59KAS3AV+FW^huNlUr5CIX{aSR`gujPlS~ zy~LRuD)lfnAgW?e$sB!&oII#4`U6x z@(tyf<=7ZnrwrI0@f4xt&=-?0_Wi1$f{mjDG=#0_2`P{;#TVBUJLqm|6-Uq)O6eI> z_#`~!JmjTLZ;dSNWw_1pOI%@Iebx<=y-DkLsNEAjX5OPiatj|A+%<3i*77e`N<+Yy zh(apYI{*TB>Y%{^|u(VHW8;;kXh>qcC4nLD}rtv z^N<5bY>a748%gmDBkiBY!j&EF8U}A3>7a^vSwq!|fT<4uz?H^u%@NsdFW?a{1z^VN z1~}sNVmbec)W=>IU3oiP;5_5Jl+&aUGQHNM0{4x1@bSYfMHyyP9FHm7GEQos4?sIiQvb+l2x$Nw z9jFdN(T?G;!(?PmmQ1GBpE+5JnD>|lLRE?9TTPy!x-Vu8g|42B5=|2!xbviNN2QJ{ zSTI{N+tgTJ2H!1a`NjF6JeIR=@N_+P#dp;T zvCKdpWzxl(b2#%j(_O<}h?{)t@Rtmv6Uo0u6l~1t%{7_f#T|uZgd>KPg~f+!gH?rd z4DXO|FHqx?I$SE-==8t@ps**aa-y@D{-_xq!%Ebx7wjW^1rq@Gbyrdfw<1=wg z+Dgg_b2x3|B`PnZf^UfJQuMMw4O#Rj%8AcV0$Ou%X0}KB#M<@|^(nymG~3+$8qrw& zR9_J9w&nV{7MR{g3I=%(k;XUNjP^XNKj{D%hLN+AkS6MABgbDH(MCnJetm!_Wl5@v8mA5Ft$NWOL}h5 zcMSdF;A>v01?o9>Or2(kXeW^q(!Nh(-ND=#I%5{~Ra%$Ij>v zKT^r$J}ef_$-=(|X}%w{b&sW(OS-R0SlG}r@Qt@^UQqNR-)f;d1dAaM{t7oSd{ zLlHqfprpW5!z6J@Ub#Fkg$7shP<{K$W?|H9xXLaF=23TT?mevfM4FezY@ zhTn%Fv)NZp7Iu$J3J04tNsipk1i$>J)UsjKy>0lnOXLTckk>NAG$bA*FO>L@ z+;5VzCZF+MP>zwEBYPvCv3cb&7r@51%C{PYEQ={;@@QuG#MG&Bo4Z{NC`(gO3;Ji+ zIr*AVa`)@ImcsHJhGq$Yh;)7^wCVI#9c&})VPek9A(y@R0ZGEyO+S-i=z5n)+^>+g zcywu4!)RW~))t|)<)DKIf=EPv^d?`^`m(Br;nwBrlE>pO%Wd@$mVcSkRvns)gNm{W>q;b$zgr&CgYkTrfu`vxuAL4C3HUGK_IC&!DNs(xO>#3 zUfD%ojohE|NaKCM>7am7t>kh^{NO7GvP0^frOl6UP{?1z^G)_vV!Cin{G~lF(||=6 z56$#toiLnI zfzp*ybCPGB|1f_k*=Fty?NZYIBLGU4viVN<>ht>7TvR^8GS@OD{If>MleNjPwlTCb zw5Z7rMJ$#RP9XM(yw=H^L~5XS3S5po{J=M9PFhX|f)-R(Yc6-@JEo>QTg0+WtZK<> zH>Xc^oZFw6cD5+(u?(oOl(DBmb1l11pjH}1&5qzBsdir3v~n=YYTD{K`NQBkqgu66 zJw3?j67%I1WsnDco(_}zQQ1#%b#jBhque!o4XdM_#W@)u97cZhV-3N$@N0rYfpzNN zXA{jwjFa|H%pKLjAzxz=ppl^KGIeeKR_cM3<2ZjJEEl&V$mvGoE-ZW;&*sbyWOLLV z(0i}`ykxngz!zrMgti*Cg5yWvhiWivJK#8SIgIOS^gC~ZYfFC%J0!Zkn@#VVIstk& z13qDBz>VAmy$@8CdpK9tn)v1~ky-mDq5uTx{ATLhQA{`Z_Lwd)kx#;BgC|HfmIgmK zEwCpc&-L3BkXgxL#(GK5453mqcY+JwL>G1AnTT)TD4DXI+S=fJ20+{0F`XGfARHjn)IF1G)Qq zU_aZ6bkcXXT@tf2HrWiiNqjs+l88C696{JcEIlh#X6FJ=L*Uum|l&*+5Q1So_BIVgl zm;i#&MnhZXqOKWy+Bv9M0X(f{&sX)P-Wv1m5~Cnz&lZ-KA&c@N4MsMzXw-@}BC+3lvSGBpmn# z7lLD-ZlUz#@DgbbY#UR$q|*8XsBJ9Sj)J>$hDNOq*xU$xVwGe4GZ5fnVPYj@u!_Q6 zv%VmMzYtb)rn;z9Sqqshuo(|zX&|(k!RYdzDJf?#!z)>oBF(4R4s%5SNV3Nu6HJj- zw68pX#!UXHCNsLGV4q;UQKtU8J?y4fy}oKoZT6~6j#}YmQ16Ka6SOi5oDmC*V=Zhz zEqq)pbZ#wNO)WleWRUF@r1GfHQ#bBWP=&l`jE^IV%anjqiJ-Ke63o_5fMhCY6UxOK zf)hhf(%k#XEvHCYY^k6q3$G4>w;c?{ITE|R`?dUicAewI3T-kdv}Az8EVD^Y7luTS zj159%wq;Y_sM}uc&o`0s$F>yP;Z$E6MlsoOlE=cJ@!7$zm2n-zU|#RS&`Sgi)fpZT zWi%GpUu(r%&&@yFJd-=qaQxfNpU;fMy*_FsC8Mz{haO`*0=bZ~0+u0lJQyI!v!akK zc8osz;)Uc<6YV=!Y};Zdg??{B>M@zYMKkr0eM~l=%k!WWmIHD`#!>@DHTguX`!o6Q z@15yRjImBOX|PuXR81dhsEFk}f1t}vr^#nc@Z7X&P5^Dwim;BL;+}W`>%|Z&NxLG@exZDk%Z4X? zG0#SX>oV@wA2?MThV|w6je{>U$3ca~24F*X z40Y+*J6N%!>6#aet36`7Q||8hd)QE;mVY^E;wZZP1PO}|8Ne}@=t0vhFUk=cOOq_m z&mUooa%)QZIhgww>`@S5&Qs+W5uf-{*kZ*P8x$Ikg)W;Md_IK*Unq+o=c`z@1>xAQ z8F$m~qalkFK#vYg?eFTkSPbHI_NFxD5n5sZk9Anr}c)}YjY!w61 zCnHI`$R?vcc~MM8t9ubn#@KnGO~(52^!+^`a8WY#(shx`0CL1rO=3F711A%8RXAkP z%VwGC1Mm2rb3Un1l!a>`FR9U#WoZml94@#c*3vEq+xK(t>$}La$tgo_r`b?%i1u^q3O0_}EcGvuf$Vk= zCya8-FVDryOG!mpbhk11v4gordguzxV6Ua%z@^6XuVWd(N=)LsR?S}UA;z>}Tpav* z&DLa4xlC~Kny!_$HJS(kTzx&3BFm}91wl1anIEZKm|2MW6^#?z6L%Z5uhSpSQoreA z$0R(tiDIoe_zD3lu?C2hG(&Je9rj}vohF(zBq=dp04r*ll5?)m2UlV(AL?8cFB9DR z`<9y1Ey%!rmdxfg0#|0M`fr(;a)lKUR~w&1+gvIlWbIKCWLN=0?&$gdvv0MQI!3u-NQQt zA8sM#9=Pqq>+z=_^J?&-F(%`6yY``4ho2EY8Q7t(sFs*=+J@#HAh!M`sgF?TgI7lY zk9=~-W2<++l`b(|{2D#gmnMF#T|*VPiVt7^lKtn!8D20L?gE8@eyy-#yRUkSeM&kJ?X@sX zn*>Ll^k2D%k9;{MuSsUNC1$r7G8R?EoEP~QH|WTdmkl|isC28`R!(C~y`6t%C|IeB zRv323`|9hXbyzE8emP2kvyxB50eVRiTB-8)$Dbe*ohV~LwvU5e%j$t{_LbO{zP-$j z&G3_MISknWWMJ&sv>tZc5nk%s!!AhB95Ctt({{E@*17Q`KR4i5 zK7HTjxAgkmoM-Z=N1OnMQ4NEtvX->gB4WIKB|Zina!-{?bBT#&wQu5rqf@nl;MX}W z8A0``xU4c?Vk#|F?;lvJWb9coYeld71N|Su2lGNg_+m0-2m6I3X1>o1v;vWTbGf7` z)?`S3@Cv(GTjK-pi2<@fdP!#DkJ2DJ8Dafl1i0+>hzJ?U3hP=g9&4qfKJrfQIV%o% zbeQ)Bev&={Q@uYNY>Kq6&bGjK9dPkppl~0xC?~ zOZ@L%GkZ|ee%a@w8r~yAu*GZL1h;y^4ZhE}=a;>n5WMn#nSt&N6&DAUu6I{NzM9L9 zvqVGz;N3DLd4aQmJ;6M*1fERH-i?O!`Z<{0O}cd9=ifeF^*d@5kRIP_^QXTPk@Bn2 zZ-TuEhk?|_7}vioL%BR+hS%J;2TbIcDX%?56sSA#@tlyF<*`xGEeT0=_#5uP)%uT| zjr|4YUom8dYK%uremYkKxIQ}RM0+s$J6-fTi9Rg+bxA7gbWj)^e@mDP>f&X1+I;oA z+8O`eS82aBMA}K(;F_w*em|v3x{u$#nLh8-OGtdh<#|>UdAV5?V3ejEmvgw`wH^$$*Gth`K z3oE0vd78P0H$X1V!l! zm@fyHvF75C`H1ICIZ?6l@X^&~It7lc^n&pV-e1Nh9uEM{QQ#P-Hez;(a;>jF1+zLt z{!9s&UUhSq&AkzocKw|gZ%IRK&9SKJ^z?*ev1E3KX3_%O`RkN6rd4ydz?YFRPTNlB zxmUb4&(v?5cRh5~9xSo+s{sk9UVMAMxEmk;HdD1yWi*M%%0$EU&4V*VtsOjdH)mCY ziv2%ig4X5We_qQXKJ_M|b~&Cexs`{8^ZX5O0@bRB$~-$Y;M;M#@ouFr%NaK}EY8F| zM)3*$f?ZuMqZyms`SAKcfu&wYaNhHm7!qX_kzD+nI3V}s)QtNOid#ny%VxvVM%ZeV z{d&zqyGyHX=ly-LOhxHiNfNQZI?pDah$qwZMVin)ckj2We+ZzQDT}lrw1A`gYIFG& z_e=G4evRbGu66jhI*d=eqKCA_@qMT!YBk3E0v=Xqs0Q;{vppF0gqz;|7$Z!}_t9E|h8m4p&A z45O}}yL-|hOy#9r+K{!|)M#AA)Pt7Es=c<$9DVF&E*)XK%f4Q=xA@<~^|BDLN+j83 zI0c{~2yYjh`g-=h?YgFx)huFrhX-^KJbXE6>ap0|DO!B}mHSsb=YZKKQIQYVs~T_m`N{5Vi>6cA z&v_H?cCB|LJOWR}-v3zWjPuHO#!S6!9zpiYkzLCny#>PN$s3eoxc%D4f#T+;M%xpm8X=b%SpD};>zJ}#F zff5aWEPZ5H)x%HMl*_OMv;BwcUntktqVH`z=nQ9UC{cLXTfmQMlT(DTT8o|?_JFGo zMVQzM7LTQ1iiQa(E@#YwvoV-kjyqU;-7qIrEj7`~dr;S6a3I}ahJD;`#wyt&Wu4?g zs#tOBrpv4;=Z59u4f;G|6uGr;zHc6i4U;N}HEEF1!lsw)O=*&JIAJ-Lz4;F>JD2!W zA^vN_>gu~=6S!smt>hJ!!;#dkE7NxknS(i7$zX$t+Mb)k5ni5KtE_S^@ge_l=~u>| zZzp=+Qi3)P{;YgEuMhvMzGN88Fm^S~b-uJ`z}+Loge4giM)Na6>EGNoAE>N175jTH z#KLjuQnl0hBtkFSy;7LgyP?N86LZbpv-9v02+yKt+v$JSGA5u$7aCB*T=G!1H(q4@ z%2aCC%o>R!+3NU85Xh_cH>SyF==V-+b;TV*r?2)IlYIA*RmQ1o!=-;)V<_-LLg!cm zt_!3+?P%XKZIfROm1PZ`URO-g@D1Y1>Y!mX8xHM=3D82o;Nzd!St)fS5~$h^4?t-$ zPU8(G!rZ;YS&?KE@>QC?rQTU36pH{wGJW1jlSagEedQ>{}i`fFA)q7!0hg5m?vi z{ueUEFC5eM6o$}k4D#uoxmDOZdg&XH4Ki%dv(G5%Q}9XPD1>BoSMh3~CwI6{8?V=! z87U~#Imb`)Uxl-H?Duuv=gEW0%_ZhS099mciZhM){Z7&1ymLbNZ%+J3vOu7l&>KtbSIFo$ zuDYQ0xW##`NPg+}$I#q@?zcuVS;Y1wS)-;VPGUbEGLEjnZ9LJ7_M?Ttg@tuL{H@7< zFO#(eo0)x^GE%8D__dw&2NM!iid6wN#zJ^KJN@wAr^_b5@xoOJ{eCU&uc)MT1T_wP&d~y!H>Hj_F*LM69=}E@suJJ@t%S`20`KpzSMVpW;6U2!Qwg z0SxZ!>X7HIAEWU39(qHVShG9aQ}~vA+|wu0mbw<7vpQ zs5Qv1gqws!qm$C=9l^r|855$V8D^bD?ltjWR7)f~Hz7uC{Q zct1^%(;?g}HJT45Dlb1Ssp1QDxTKIMttIDZNFg8{Fdlfg5vww{+S8l=I59 zGse8-GhM0~0lQ)MqIEr$Q*j!$D68oxn`GnD$|xm`QR=yvQ|TG<%x8`4Bv33KRx)_{ z*rsOBIsLR>$uGnpM8OyNH=Rp|_WB6x z$CiQ3(e9Pg(T<43Xk&|wL4)ISF|Lm3i1|H*A7fe3X`T6But?1Y;I+tnYIeeL04D5i zZk;a7aVtS-c@}b*{DuF%n+cSBCtPIqE zG-a_c2P$n?+$w}>U@ey1#~5ECV?+j{Z{G@?(~cXp0;U1JWmRN}0WEJf zzon*BzIj{a#!oD+_TLVC@gAq+CK2$W?SE`e)S7MtY^3G2=lXV}L(kF(H` z)_vZ$sHxj=Z5b$!< zQZk0DpSK6N-&pyvf_GKP(7QEgoFG)>S%2y^W;;@q*Im!LvL{U)P_;{B5g17B2 zy8d-{_TFYVzYW-U<$+QWiM`k2;pCs4s0RFt1y zUpO*!sncja=RFmT3;MJGSdy{MyU@$RSug$9N0Zp@x1V&+y=vccx#JTbQ-1y8cz86r zVcdfM8k5siSKq6cMlu(3)wBDdmO~I(j}>rW^J{t-SUeuAD(dbhfmI&v0c6(h&Gt9< zeigkt1z$**U9n5X3{YL&v9f=ZC9x`~TVFFHVHYvoi0vp_4sG-DJoX60l6_%d_Hdqy zxgT|(aL?u5>ph87m$^4f>eVD5$mj@|^|1Ph7yI6vNw(Ur^h7n1EW$kLu?%TcJ?qt8 zzrL!7Z|22x6)5&<0;nY$V|JVtx$vztr$WYl@-Qtgk6r}v37ftJ!=TJR=5NfM4nb{_ zMRYjLPJ0UBa$YrH4(*jFRPd|UZciWf9GMyjW{e&n6oy;-pT&tTcx=P|!P!;4SX zc{;WLyuBIVA(Z*tykUR3fnWCUBlooZA3MAC2E2V8%NZ8cqmo@tpkGg4dN$p$SFe|V z;F*lVD(8CNCJBZIm)WL1@6I>31g%aJx3->~@vTN3;k}*R{-47J+dNCJB)xXy$)rn? zzJmT$``dD#Z)2^mmHw(zpRiUO)&9&8L|ah-1+#^6 zGVFf4osUMRYd2sRW9TOL^`22@F00*5cT!ZjcUHD>UO7x}+}i~_!iaXq2s=Nd;sHf@ z=9D4FKMMC&ALaK0(Ps#nc^Nd1Z>9EKhkJPEbk1_)&l|emm%@Lpb)m>?_h68@(m4+_ zJaB@Ws8|JGCz10Z4|bz*yJ_O-1CJg;T~lu6eV|%{bCGkdGy*=5;I->kkhE9;e+IAp zSKkcu-PBH-;S?=sjX9l#ER~DxqptpYFsPRF#j5N;O2z)pX9h5}Y$KW=(!y<@%G_B# zZBwy}aV*lR5r$53UefIw?DCIvf*X!1a2m*W_;oW>ypaqaOzea{O|WRh z$A49?r4nA_c3)SMbmDU4GpN6~sKRI)q#XficxO8786$^dO6s%W$Rt_e)hkZ2qIm4} z(SfKEZzM+;l|Z95_Nq;IM15K??($36XJo1 zUbhz9FTZn8o*O3v_A+GWf5boQjiTl9|8AS^gOkyrV^>5~vU@F6amh9d`Zm$uhvgV2 znkWClHPLbw9&jp^pNMLl$0O?7mu>Kp8nCGW_g+a~)&_}0*`9YCnV#7&SK0n0$X{4w zR9f#fHli}Y#b&hsc&DZ;mS2hVqBFAiW_S|#aCDw&Jg~jVs9%2y+LH#urct?@4E{Jb z-q9aou}pDeiWe1OTYuGjc$S_=_hqVlDc#Kk;L_@I|NED%+^O+$=Vk2AJ+S+{bAmES zOw%L>#GjCb2D1QtnoXyZ4+XV;kTK@{u9;s^>jgh3;(1@wKAU-Q2Ubp1_C=+uf*Y?1 zr@NmmnpPKV(b|>;g!piHPp&jgp8s~Pm${m_=Za#)PVyq_a@v%~@r{va} z*x&5lU?%e#*lU-nPkTSbiavv6thxrB29eVK_!QVBI%c%=O~fXK0aJnbrX0g2H1858 zKR|6vNt(b0r9CG{?keHuDK)URY3#I*)9h=5kAd|)$+03W)AV$$(B4bSrXeI@gNGzJ zmcqNLBMq{mW9Cb{*Oah6>iWYQOtQIOnTYLE8qoIJ8>OcsH*eL}HtP4lCQFxl7O7JC z0V#Tfh-bd=`*dLo>3;vrRi4|wdXIjpb0cNq4dIm?r{o{Z-FieGB&%SZ42HEAcn41~ zp@*-Q>x((aR0UE69~ZW;-MT^u`3-vh+$}i1PA#DP6m6}f^@YOmeK|+)akPCOkAxTi zj1Mdb_PQeYD>&o4KE3_s0Ff45kDD>Z$6s`~K|*9qx3r1t_OwbJvz=3HkLmn5FlOeX z;v;Fz|7vpAL;)nQm3FJiVDh~qn>W9cd5Vl^UFj-;#E7&mS<$`P&KvFXx0wB@jYt!g z&>}dp0M^D4FV!<2l_Y>DP9rJA$Nbirj3~_N(UF~Ub4E+9V5LjbXpjrzPdW2)4DW>- z{R3L%>)Kc)B)(q_TpyAMF9^3M+2?kC6!}+Uoc)|<05A^FJikmq0GLOEO=4?{NdQ_- z*&7K8%g;EA>-+MJeudBFR8LC*hM)Pt>=)uU$I8a*y-b3ZyQ_7RufLg8|33dLFax_L z1uixozUG?rJm&jVmTQEJD6PMt3D_Td=YhLgH^ZBgZF3z3J87o(^Dex`MYq;C%oVt- zDpDO@%|Shm7C=5nrCEC4ZrwoTTZeKksRK8Mj}q|d>l5dA0mjw0&*Q7%!2H~Ob5t+t zB%2jD`qWy{mhS7Z!DFGqC0JTM{VP)T!L#s&7b)pkox8n%s45y=0|_A-;1&C9O!hH&6SmwJTG15VL*g$k_y+^;u;Tn*rNDJQH|o4J^qXCuKQboexO zJ=GA3 zBm6`vzDb{vG5T&Mk51m=3VqT8%TyO$S*gP7|5rqT(7>7?%)FiXwvw`ps$#w$rZ}ri z*{;yR&T%=r#<@c~Gxf>9Z*guevN<}V2OVka_;J+40Wb1tfmj z#8IdKaov$7U> z6Mo4-Wdj2Gq9!<|9r3npF-7OSBP#;@bMe)5Q?Oxud9cjNpco~-GJ^V=$Wnt%zSj<( z%E%~PNoTpRyHOG17jb8@DZ(7kc*-C-#=@N2*irN92D$0%;Xg3;Dlrr}c%STfsJ2R_ zn)9?OO;9kIIZxU#>$V9fz{-5Ak%q1eBd}#1Yd*GI18`ja?pS@8W7WuWq_$X(&H46f zu&+`UY58<3TUSPsugfbch!%?9`%izECh$;w_3 zTcCU`jVc zZIH;k-|%4t9m;(Cz2%|)>bDKj17Z82zC4(OCG$!Eh&|E z*^1$0?ROB^b{?xMZ_;{Dna7CA#}+RP(bP4}D>Pb1-}E&yX|&8rJJ)<|eSrYg6rn!> zp>Uv}pb()}q8Rkl)RtGgE})@;Q(>Txp{PH`|L;crpCc5Mi@UY0gB6R_H!I}-ZwwlW z0E&Bx$Sw?K9hwqdhClY>d{Od0_J6b6Zf$x=RAHe0sKP^`{y*%zk2|Wl%UXH3+gkoN zedCGXKlC@SuO0RLZ}9|*#I(B+-1o&TGS=t%e(_~;+j{{ef)JHG$` diff --git a/CODE/_debug.py b/CODE/_debug.py deleted file mode 100644 index 87cc8a14..00000000 --- a/CODE/_debug.py +++ /dev/null @@ -1,278 +0,0 @@ -from __future__ import annotations - -import configparser -import os -import platform -import sys -import time - -import psutil -import requests - -from logicytics import Log, DEBUG, VERSION, check, config, get - -log_path = os.path.join( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))), - "ACCESS\\LOGS\\DEBUG\\DEBUG.log", -) -log = Log( - { - "log_level": DEBUG, - "filename": log_path, - "truncate_message": False, - "delete_log": True, - } -) -url = config.get("System Settings", "config_url") - - -class VersionManager: - @staticmethod - def parse_version(version: str) -> tuple[int, int, int | str, str]: - """ - Parses a version string into a tuple (major, minor, patch, type). - """ - try: - if version.startswith("snapshot-"): - parts = version.split("-")[1].split(".") - major, minor = map(int, parts[:2]) - patch = parts[2] if len(parts) > 2 else "0" - return major, minor, patch, "snapshot" - else: - return tuple(map(int, version.split("."))) + ("release",) - except Exception as e: - log.error(f"Failed to parse version: {e}") - return 0, 0, 0, "error" - - -class FileManager: - @staticmethod - def check_required_files(directory: str, required_files: list[str]): - """ - Checks if all required files are present in the directory and its subdirectories. - """ - try: - log.debug(f"Checking directory: {directory}") - if not os.path.exists(directory): - log.error(f"Directory {directory} does not exist.") - return - - # Use get.list_of_files to retrieve files, excluding specified files, dirs, and extensions - actual_files = get.list_of_files( - directory, - exclude_files=[ - "logicytics/User_History.json.gz", - "logicytics/User_History.json", - ], - exclude_dirs=["SysInternal_Suite"], - exclude_extensions=[".pyc"], - ) - actual_files = [ - f.replace("\\", "/").replace('"', "") for f in actual_files - ] # Normalize paths - - log.debug(f"Actual files found: {actual_files}") - # Strip quotes and normalize paths for comparison - normalized_required_files = [ - required_file.strip() - .replace("\\", "/") - .replace('"', "") # Remove quotes and normalize paths - for required_file in required_files - ] - - # Compare files - missing_files, extra_files = FileManager.compare_files( - actual_files, normalized_required_files - ) - - if missing_files: - log.error(f"Missing files: {', '.join(missing_files)}") - if extra_files: - log.warning(f"Extra files found: {', '.join(extra_files)}") - if not missing_files and not extra_files: - log.info("All required files are present.") - except Exception as e: - log.error(f"Unexpected error during file check: {e}") - - @staticmethod - def compare_files( - actual_files: list[str], required_files: list[str] - ) -> tuple[list[str], list[str]]: - """ - Compares actual and required files, returning missing and extra files. - """ - missing_files = [file for file in required_files if file not in actual_files] - extra_files = [file for file in actual_files if file not in required_files] - return missing_files, extra_files - - -class SysInternalManager: - @staticmethod - def check_binaries(path: str): - """ - Checks the SysInternal Binaries in the given directory. - """ - try: - if not os.path.exists(path): - raise FileNotFoundError("Directory does not exist") - - contents = os.listdir(path) - log.debug(str(contents)) - - has_zip = any(file.endswith(".zip") for file in contents) - has_exe = any(file.endswith(".exe") for file in contents) - - if any(file.endswith(".ignore") for file in contents): - log.warning("A `.sys.ignore` file was found - Ignoring") - elif has_zip and not has_exe: - log.error("Only zip files - Missing EXEs due to no `ignore` file") - elif has_zip and has_exe: - log.info("Both zip and exe files - All good") - else: - log.error( - "SysInternal Binaries Not Found: Missing Files - Corruption detected" - ) - except Exception as e: - log.error(f"Unexpected error: {e}") - - -class SystemInfoManager: - @staticmethod - def cpu_info() -> tuple[str, str, str]: - """ - Retrieves CPU details. - """ - return ( - f"CPU Architecture: {platform.machine()}", - f"CPU Vendor ID: {platform.system()}", - f"CPU Model: {platform.release()} {platform.version()}", - ) - - @staticmethod - def python_version(): - """ - Checks the current Python version against recommended version ranges and logs the result. - """ - version = sys.version.split()[0] - MIN_VERSION = (3, 11) - MAX_VERSION = (3, 14) - try: - major, minor = map(int, version.split(".")[:2]) - if MIN_VERSION <= (major, minor) < MAX_VERSION: - if (major, minor) == MIN_VERSION: - log.info( - f"Python Version: {version} - Perfect (mainly tested on 3.11.x)" - ) - else: - log.info(f"Python Version: {version} - Supported") - elif (major, minor) < MIN_VERSION: - log.warning(f"Python Version: {version} - Recommended: 3.11.x") - else: - log.error(f"Python Version: {version} - Incompatible") - except Exception as e: - log.error(f"Failed to parse Python Version: {e}") - - -class ConfigManager: - @staticmethod - def get_online_config() -> dict | None: - """ - Retrieves configuration data from a remote repository. - """ - try: - _config = configparser.ConfigParser() - _config.read_string(requests.get(url, timeout=15).text) - return _config - except requests.exceptions.RequestException as e: - log.error(f"Connection error: {e}") - return None - - -class HealthCheck: - @staticmethod - def check_versions(local_version: str, remote_version: str): - """ - Compares local and remote versions. - """ - local_version_tuple = VersionManager.parse_version(local_version) - remote_version_tuple = VersionManager.parse_version(remote_version) - - if "error" in local_version_tuple or "error" in remote_version_tuple: - log.error("Version parsing error.") - return - - try: - if "snapshot" in local_version_tuple or "snapshot" in remote_version_tuple: - log.warning("Snapshot versions are unstable.") - - if local_version_tuple == remote_version_tuple: - log.info(f"Version is up to date. Your Version: {local_version}") - elif local_version_tuple > remote_version_tuple: - log.warning( - "Version is ahead of the repository. " - f"Your Version: {local_version}, " - f"Repository Version: {remote_version}" - ) - else: - log.error( - "Version is behind the repository. " - f"Your Version: {local_version}, Repository Version: {remote_version}" - ) - except Exception as e: - log.error(f"Version comparison error: {e}") - - -@log.function -def debug(): - """ - Executes a comprehensive system debug routine, performing various checks and logging system information. - """ - # Online Configuration Check - _config = ConfigManager.get_online_config() - if _config: - HealthCheck.check_versions(VERSION, _config["System Settings"]["version"]) - - # File Integrity Check - required_files = _config["System Settings"].get("files", "").split(",") - FileManager.check_required_files(".", required_files) - - # SysInternal Binaries Check - SysInternalManager.check_binaries("SysInternal_Suite") - - # System Checks - log.info("Admin privileges found") if check.admin() else log.warning( - "Admin privileges not found" - ) - log.info("UAC enabled") if check.uac() else log.warning("UAC disabled") - log.info(f"Execution path: {psutil.__file__}") - log.info(f"Global execution path: {sys.executable}") - log.info(f"Local execution path: {sys.prefix}") - log.info( - "Running in a virtual environment" - if sys.prefix != sys.base_prefix - else "Not running in a virtual environment" - ) - log.info( - "Execution policy is unrestricted" - if check.execution_policy() - else "Execution policy is restricted" - ) - - # Python Version Check - SystemInfoManager.python_version() - - # CPU Info - for info in SystemInfoManager.cpu_info(): - log.info(info) - - # Final Debug Status - log.info(f"Log Level: {DEBUG}") - - -if __name__ == "__main__": - try: - debug() - except Exception as err: - log.error(f"Failed to execute debug routine: {err}") - time.sleep(0.5) - input("Press Enter to exit...") diff --git a/CODE/_dev.py b/CODE/_dev.py deleted file mode 100644 index b9863125..00000000 --- a/CODE/_dev.py +++ /dev/null @@ -1,223 +0,0 @@ -from __future__ import annotations - -import os -import re -import subprocess - -import configobj - -from logicytics import log, get, CURRENT_FILES, VERSION - - -def color_print(text, color="reset", is_input=False) -> None | str: - colors = { - "reset": "\033[0m", - "red": "\033[31m", - "green": "\033[32m", - "yellow": "\033[33m", - "cyan": "\033[36m", - } - - color_code = colors.get(color.lower(), colors["reset"]) - if is_input: - return input(f"{color_code}{text}{colors['reset']}") - print(f"{color_code}{text}{colors['reset']}") - return None - - -def _update_ini_file(filename: str, new_data: list | str, key: str) -> None: - """ - Updates an INI file with a new array of current files or version. - Args: - filename (str): The path to the INI file to be updated. - new_data (list | str): The list of current files or the new version to be written to the INI file. - key (str): The key in the INI file to be updated. - Returns: - None - """ - try: - config = configobj.ConfigObj( - filename, encoding="utf-8", write_empty_values=True - ) - - if key == "files": - config["System Settings"][key] = ", ".join(new_data) - elif key == "version": - config["System Settings"][key] = new_data - else: - color_print(f"[!] Invalid key: {key}", "yellow") - return - - config.write() - except FileNotFoundError: - color_print("[x] INI file not found", "red") - except configobj.ConfigObjError as e: - color_print(f"[x] Parsing INI file failed: {e}", "red") - except Exception as e: - color_print(f"[x] {e}", "red") - - -def _prompt_user( - question: str, file_to_open: str = None, special: bool = False -) -> bool: - """ - Prompts the user with a yes/no question and optionally opens a file. - - Parameters: - question (str): The question to be presented to the user. - file_to_open (str, optional): Path to a file that will be opened if the user does not respond affirmatively. - special (bool, optional): Flag to suppress the default reminder message when the user responds negatively. - - Returns: - bool: True if the user responds with 'yes' or 'Y', False otherwise. - - Raises: - Exception: Logs any unexpected errors during user interaction. - - Notes: - - Uses subprocess to open files on Windows systems - - Case-insensitive input handling for 'yes' responses - - Provides optional file opening and reminder messaging - """ - try: - answer = color_print(f"[?] {question} (y)es or (n)o:- ", "cyan", is_input=True) - if not (answer.lower() == "yes" or answer.lower() == "y"): - if file_to_open: - subprocess.run(["start", file_to_open], shell=True) - if not special: - color_print( - "[x] Please ensure you fix the issues/problem and try again with the checklist.", - "red", - ) - return False - return True - except Exception as e: - color_print(f"[x] {e}", "red") - return None - - -def _perform_checks() -> bool: - """ - Performs a series of user prompts for various checks. - - Returns: - bool: True if all checks are confirmed by the user, False otherwise. - """ - checks = [ - ("Have you read the required contributing guidelines?", "..\\CONTRIBUTING.md"), - ("Have you made files you don't want to be run start with '_'?", "."), - ("Have you added the file to CODE dir?", "."), - ("Have you added docstrings and comments?", "..\\CONTRIBUTING.md"), - ("Is each file containing around 1 main feature?", "..\\CONTRIBUTING.md"), - ] - - for question, file_to_open in checks: - if not _prompt_user(question, file_to_open): - return False - return True - - -def _handle_file_operations() -> None: - """ - Handles file operations and logging for added, removed, and normal files. - """ - EXCLUDE_FILES = [ - "logicytics\\User_History.json.gz", - "logicytics\\User_History.json", - ] - files = get.list_of_files( - ".", - exclude_files=EXCLUDE_FILES, - exclude_dirs=["SysInternal_Suite"], - exclude_extensions=[".pyc"], - ) - added_files, removed_files, normal_files = [], [], [] - clean_files_list = [file.replace('"', "") for file in CURRENT_FILES] - - files_set = set(os.path.abspath(f) for f in files) - clean_files_set = set(os.path.abspath(f) for f in clean_files_list) - - for file in files_set: - if file in clean_files_set and file not in EXCLUDE_FILES: - normal_files.append(file) - elif file not in clean_files_set and file not in EXCLUDE_FILES: - added_files.append(file) - - for file in clean_files_set: - if file not in files_set and file not in EXCLUDE_FILES: - removed_files.append(file) - - print("\n".join([f"\033[92m+ {file}\033[0m" for file in added_files])) # Green + - print("\n".join([f"\033[91m- {file}\033[0m" for file in removed_files])) # Red - - print("\n".join([f"* {file}" for file in normal_files])) - - if not _prompt_user("Does the list above include your added files?"): - color_print("[x] Something went wrong! Please contact support.", "red") - return - - max_attempts = 10 - attempts = 0 - _update_ini_file("config.ini", files, "files") - - while True: - version = color_print( - f"[?] Enter the new version of the project (Old version is {VERSION}): ", - "cyan", - is_input=True, - ) - - if re.match(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$", version): - _update_ini_file("config.ini", version, "version") - break - attempts += 1 - if attempts >= max_attempts: - color_print( - "[x] Maximum attempts reached. Please run the script again.", "red" - ) - exit() - else: - color_print( - "[!] Please enter a valid version number (e.g., 1.2.3)", "yellow" - ) - color_print(f"[!] {max_attempts - attempts} attempts remaining", "yellow") - - color_print( - "\n[-] Great Job! Please tick the box in the GitHub PR request for completing steps in --dev", - "green", - ) - - -@log.function -def dev_checks() -> None: - """ - Performs comprehensive developer checks to ensure code quality and project guidelines compliance. - - This function guides developers through a series of predefined checks, validates file additions, - and updates project configuration. It performs the following key steps: - - Verify adherence to contributing guidelines - - Check file naming conventions - - Validate file placement - - Confirm docstring and comment coverage - - Assess feature modularity - - Categorize and display file changes - - Update project configuration file - - Raises: - None: Returns None if any check fails or an error occurs during the process. - - Side Effects: - - Creates necessary directories - - Prompts user for multiple confirmations - - Prints file change lists with color coding - - Updates configuration file with current files and version - - Logs warnings or errors during the process - """ - if not _perform_checks(): - return - _handle_file_operations() - - -if __name__ == "__main__": - dev_checks() - # Wait for the user to press Enter to exit the program - input("\n[*] Press Enter to exit the program... ") diff --git a/CODE/bluetooth_details.py b/CODE/bluetooth_details.py deleted file mode 100644 index fb28dcdb..00000000 --- a/CODE/bluetooth_details.py +++ /dev/null @@ -1,144 +0,0 @@ -from __future__ import annotations - -import json -import subprocess -from typing import TextIO - -from logicytics import log - - -@log.function -def get_bluetooth_device_details(): - """ - Retrieves and logs detailed information about Bluetooth devices on the system. - - Executes a PowerShell query to collect Bluetooth device details and writes the information to a text file. - The function performs the following key actions: - - Logs the start of the device information retrieval process - - Queries Bluetooth devices using an internal helper function - - Writes device details to 'Bluetooth Info.txt' if devices are found - - Returns: - None: No return value; results are written to a file and logged - """ - log.info("Fetching detailed info for Bluetooth devices...") - devices = _query_bluetooth_devices() - if devices: - _write_device_info_to_file(devices, "Bluetooth Info.txt") - - -def _query_bluetooth_devices() -> bool | list[dict[str, str]]: - """ - Queries the system for Bluetooth devices using PowerShell commands. - - Executes a PowerShell command to retrieve detailed information about Bluetooth devices connected to the system. - The function handles potential errors during command execution and JSON parsing, providing fallback values - for device information. - - Returns: - bool | list[dict[str, str]]: A list of device information dictionaries or False if an error occurs. - Each dictionary contains details such as Name, Device ID, Description, Manufacturer, Status, and PNP Device ID. - - Raises: - No direct exceptions are raised. Errors are logged and the function returns False. - - Example: - devices = _query_bluetooth_devices() - if devices: - for device in devices: - print(device['Name']) - """ - try: - # Run PowerShell command to get Bluetooth devices - command = ( - "Get-PnpDevice | Where-Object { $_.FriendlyName -like '*Bluetooth*' } | " - "Select-Object FriendlyName, DeviceID, Description, Manufacturer, Status, PnpDeviceID | " - "ConvertTo-Json -Depth 3" - ) - result = subprocess.run(["powershell", "-Command", command], - capture_output=True, text=True, check=True) - devices = json.loads(result.stdout) - except subprocess.CalledProcessError as e: - log.error(f"Failed to query Bluetooth devices with command '{command}': {e}") - return False - except json.JSONDecodeError as e: - log.error(f"Failed to parse device information: {e}") - return False - - if isinstance(devices, dict): - devices = [devices] # Handle single result case - - device_info_list = [] - for device in devices: - FALLBACK_MSG = 'Unknown (Fallback due to failed Get request)' - device_info = { - 'Name': device.get('FriendlyName', FALLBACK_MSG), - 'Device ID': device.get('DeviceID', FALLBACK_MSG), - 'Description': device.get('Description', FALLBACK_MSG), - 'Manufacturer': device.get('Manufacturer', FALLBACK_MSG), - 'Status': device.get('Status', FALLBACK_MSG), - 'PNP Device ID': device.get('PnpDeviceID', FALLBACK_MSG) - } - log.debug(f"Retrieved device: {device_info['Name']}") - device_info_list.append(device_info) - - return device_info_list - - -def _write_device_info_to_file(devices: list[dict[str, str]], filename: str): - """ - Writes the details of Bluetooth devices to a specified file. - - Args: - devices (list): A list of dictionaries containing Bluetooth device information. - filename (str): The path and name of the file where device details will be written. - - Raises: - IOError: If there is an error opening or writing to the specified file. - OSError: If there are file system related issues during file writing. - - Notes: - - Uses UTF-8 encoding for file writing - - Logs an error if file writing fails - - Calls _write_single_device_info() for each device in the list - """ - try: - with open(filename, "w", encoding="UTF-8") as file: - for device_info in devices: - _write_single_device_info(file, device_info) - log.info(f"Successfully wrote device details to '{filename}'") - except Exception as e: - log.error(f"Failed to write device information to file: {e}") - - -def _write_single_device_info(file: TextIO, device_info: dict[str, str]): - """ - Writes detailed information for a single Bluetooth device to the specified file. - - Parameters: - file (TextIO): An open file object to which device information will be written. - device_info (dict): A dictionary containing key-value pairs of Bluetooth device attributes. - - Writes the device name followed by all other device attributes, with each device's information separated by a blank line. Uses `.get()` method to provide a fallback 'Unknown' value if the device name is missing. - - Example: - If device_info is {'Name': 'Wireless Headset', 'Address': '00:11:22:33:44:55', 'Connected': 'True'} - The file will contain: - Name: Wireless Headset - Address: 00:11:22:33:44:55 - Connected: True - - If no name is provided: - Name: Unknown - Address: 00:11:22:33:44:55 - Connected: True - """ - file.write(f"Name: {device_info.get('Name', 'Unknown')}\n") - for key, value in device_info.items(): - if key != 'Name': - file.write(f" {key}: {value}\n") - file.write("\n") # Separate devices with a blank line - - -if __name__ == "__main__": - get_bluetooth_device_details() diff --git a/CODE/bluetooth_logger.py b/CODE/bluetooth_logger.py deleted file mode 100644 index c6d18819..00000000 --- a/CODE/bluetooth_logger.py +++ /dev/null @@ -1,179 +0,0 @@ -import datetime -import re -import subprocess -from typing import LiteralString - -from logicytics import log - - -# Utility function to log data to a file -def save_to_file(filename: str, section_title: str, data: str): - """ - Appends data to a file with a section title. - - Args: - filename (str): Path to the file where data will be written. Must be a valid file path. - section_title (str): Title describing the section being added to the file. - data (str or list): Content to be written. Accepts either a single string or a list of strings. - - Raises: - IOError: If the file cannot be opened or written to due to permission or path issues. - Exception: For any unexpected errors during file writing. - - Notes: - - Uses UTF-8 encoding for file writing - - Adds decorative section separators around the content - - Automatically handles single string or list of strings input - - Logs any errors encountered during file writing - """ - try: - with open(filename, 'a', encoding='utf-8') as file: - file.write(f"\n{'=' * 50}\n{section_title}\n{'=' * 50}\n") - file.write(f"{data}\n" if isinstance(data, str) else "\n".join(data) + "\n") - file.write(f"{'=' * 50}\n") - except Exception as err: - log.error(f"Error writing to file {filename}: {err}") - - -# Utility function to run PowerShell commands -def run_powershell_command(command: str) -> None | list[LiteralString]: - """ - Runs a PowerShell command and returns the output as a list of lines. - - Args: - command (str): The PowerShell command to execute. - - Returns: - list: A list of strings representing each line of the command output. - Returns an empty list if the command execution fails or an exception occurs. - - Raises: - subprocess.CalledProcessError: If the PowerShell command returns a non-zero exit status. - Exception: For any unexpected errors during command execution. - - Notes: - - Uses subprocess.run() with capture_output=True to capture command output - - Logs errors for failed commands or exceptions - - Splits command output into lines for easier processing - """ - try: - result = subprocess.run(["powershell", "-Command", command], capture_output=True, text=True) - if result.returncode != 0: - log.error(f"PowerShell command failed with return code {result.returncode}") - return [] - return result.stdout.splitlines() - except Exception as err: - log.error(f"Error running PowerShell command: {err}") - return [] - - -# Unified parsing function for PowerShell output -def parse_output(lines: list[LiteralString], regex: str, group_names: list[str]): - """ - Parses the output lines using the provided regex and group names. - - Parameters: - lines (list): A list of strings representing command output lines. - regex (str): Regular expression pattern to match each line. - group_names (list): List of group names to extract from matched regex. - - Returns: - list: Dictionaries containing extracted group names and their values. - - Raises: - Exception: If parsing the output encounters an unexpected error. - - Notes: - - Skips lines that do not match the provided regex pattern - - Logs debug messages for unrecognized lines - - Logs error if parsing fails completely - """ - results = [] - try: - for line in lines: - match = re.match(regex, line) - if match: - results.append({name: match.group(name) for name in group_names}) - else: - log.debug(f"Skipping unrecognized line: {line}") - return results - except Exception as err: - log.error(f"Parsing output failed: {err}") - - -# Function to get paired Bluetooth devices -def get_paired_bluetooth_devices() -> list[str]: - """ - Retrieves a list of paired Bluetooth devices with their names and MAC addresses. - - This function executes a PowerShell command to fetch Bluetooth devices with an "OK" status, - parses the output to extract device details, and attempts to retrieve MAC addresses from device IDs. - - Returns: - list: A list of formatted strings containing device names and MAC addresses. - Each string follows the format "Name: , MAC: ". - - Raises: - Exception: If there are issues running the PowerShell command or parsing the output. - """ - command = ( - 'Get-PnpDevice -Class Bluetooth | Where-Object { $_.Status -eq "OK" } | Select-Object Name, DeviceID' - ) - output = run_powershell_command(command) - log.debug(f"Raw PowerShell output for paired devices:\n{output}") - - devices = parse_output( - output, - regex=r"^(?P.+?)\s+(?P.+)$", - group_names=["Name", "DeviceID"] - ) - - # Extract MAC addresses - for device in devices: - mac_match = re.search(r"BLUETOOTHDEVICE_(?P[A-F0-9]{12})", device["DeviceID"], re.IGNORECASE) - device["MAC"] = mac_match.group("MAC") if mac_match else "Address Not Found" - - return [f"Name: {device['Name']}, MAC: {device['MAC']}" for device in devices] - - -# Function to log all Bluetooth data -@log.function -def log_bluetooth(): - """ - Logs comprehensive Bluetooth data including paired devices and system event logs. - - This function performs the following actions: - - Captures the current timestamp - - Retrieves and logs paired Bluetooth devices - - Collects Bluetooth connection/disconnection event logs - - Captures Bluetooth file transfer logs - - Saves all collected data to 'bluetooth_data.txt' - - The function uses internal utility functions to run PowerShell commands, parse outputs, and save results to a file. It provides a systematic approach to logging Bluetooth-related system information. - - Logs are saved with descriptive section titles, making the output easily readable and organized. If no data is found for a specific section, a default "No logs found" message is recorded. - - Note: - - Requires administrative or sufficient system permissions to access Windows event logs - - Logs are appended to the file, allowing historical tracking of Bluetooth events - """ - log.info("Starting Bluetooth data logging...") - filename = "bluetooth_data.txt" - timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") - save_to_file(filename, "Bluetooth Data Collection - Timestamp", timestamp) - - # Collect and log paired devices - log.info(f"Collecting paired devices...") - paired_devices = get_paired_bluetooth_devices() - section_title = "Paired Bluetooth Devices" - save_to_file(filename, section_title, paired_devices or ["No paired Bluetooth devices found."]) - log.debug(f"{section_title}: {paired_devices}") - - log.info("Finished Bluetooth data logging.") - - -if __name__ == "__main__": - try: - log_bluetooth() - except Exception as e: - log.error(f"Failed to log Bluetooth data: {e}") diff --git a/CODE/browser_miner.ps1 b/CODE/browser_miner.ps1 deleted file mode 100644 index f0be76b0..00000000 --- a/CODE/browser_miner.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -# Define the list of source paths with placeholders -$sourcePaths = @( - "C:\Users\{}\AppData\Local\Microsoft\Edge\User Data\Default\Network", - "C:\Users\{}\AppData\Local\Google\Chrome\User Data\Default\Network", - "C:\Users\{}\AppData\Roaming\Mozilla\Firefox\Profiles", - "C:\Users\{}\AppData\Roaming\Opera Software\Opera Stable\Network", - "C:\Users\{}\AppData\Roaming\Opera Software\Opera GX Stable\Network", - 'C:\\Windows\\System32\\config', - 'C:\\Windows\\System32\\GroupPolicy', - 'C:\\Windows\\System32\\GroupPolicyUsers', - 'C:\\Windows\\System32\\winevt\\Logs' -) - -# Define the list of identifiers for renaming -$identifiers = @( - "Edge", - "Chrome", - "Firefox", - "OperaStable", - "OperaGXStable", - "SAM", - "SystemConfig", - "GroupPolicy", - "GroupPolicyUsers", - "WindowsEventLogs" -) - -# Get the current user's name -$currentUser = $env:USERNAME - -# Define the base directory for the destination -$baseDirectory = "Browser_Data" - -# Function to check if a path exists and is accessible -function Test-PathAndAccess($path) -{ - return Test-Path $path -PathType Container -ErrorAction SilentlyContinue -} - -# Loop through each source path -foreach ($sourcePath in $sourcePaths) -{ - # Replace the placeholder with the current user's name - $fullSourcePath = $sourcePath -replace '\{\}', $currentUser - - # Enhanced error checking for source path existence and accessibility - if (-not (Test-PathAndAccess $fullSourcePath)) - { - Write-Host "WARNING: Source path $fullSourcePath does not exist or cannot be accessed." - continue - } - - - # Extract the identifier from the source path using the corresponding Index from the $identifiers array - try - { - $index = [Array]::IndexOf($identifiers, $sourcePath.Split('\')[-1].Split('\\')[-1]) - $identifier = $identifiers[$index] - } - catch - { - Write-Host "ERROR: Failed to extract identifier from source path $fullSourcePath." - continue - } - - - # Define the destination path - $destinationPath = Join-Path -Path $baseDirectory -ChildPath "USER_$identifier" - - # Enhanced error checking for destination directory existence - if (-not (Test-PathAndAccess $destinationPath)) - { - New-Item -ItemType Directory -Path $destinationPath -Force | Out-Null - } - - # Attempt to copy the folder to the DATA directory and rename it - try - { - Copy-Item -Path $fullSourcePath -Destination $destinationPath -Recurse -Force -ErrorAction SilentlyContinue - # Print the success message to the console - Write-Host "INFO: Successfully copied $fullSourcePath to $destinationPath" - } - catch - { - # Detailed error handling - Write-Host "ERROR: An error occurred while copying $fullSourcePath to $destinationPath : $_" - exit - } -} diff --git a/CODE/cmd_commands.py b/CODE/cmd_commands.py deleted file mode 100644 index 3739dca7..00000000 --- a/CODE/cmd_commands.py +++ /dev/null @@ -1,31 +0,0 @@ -from logicytics import log, execute - - -@log.function -def command(file: str, commands: str, message: str, encoding: str = "UTF-8") -> None: - """ - Executes a command and writes the output to a file. - - Args: - file (str): The name of the file to write the command output to. - commands (str): The command to be executed. - message (str): A message to be logged. - encoding (str): The encoding to write the file in. - - Returns: - None - """ - log.info(f"Executing {message}") - try: - output = execute.command(commands) - with open(file, "w", encoding=encoding) as f: - f.write(output) - log.info(f"{message} Successful - {file}") - except Exception as e: - log.error(f"Error while getting {message}: {e}") - - -if __name__ == "__main__": - command("Drivers.txt", "driverquery /v", "Driver Query") - command("SysInfo.txt", "systeminfo", "System Info") - command("GPResult.txt", "GPResult /r", "GPResult", "windows-1252") diff --git a/CODE/config.ini b/CODE/config.ini deleted file mode 100644 index 8395bd55..00000000 --- a/CODE/config.ini +++ /dev/null @@ -1,114 +0,0 @@ -######################################################## -# The following settings are for Logicytics as a whole # -######################################################## - -[Settings] -# Would you like to enable debug mode? -# This will print out more information to the console, with prefix DEBUG -# This will not be logged however, and is useful for developers - This is different than the DEBUGGER itself -log_using_debug = false - -# Would you like for new logs to be created every execution? -# Or would you like to append to the same log file? -delete_old_logs = false - -# When using threading mode, you have the option to decide how many threads to use (workers) -# Uncomment and change the value to use a maximum amount of threads, -# otherwise keep it commented if you don't need a maximum limit -; max_workers = 10 - -# Logicytics will save preferences and history in a file, -# This is used by Flag.py, to suggest better flags -# Would you like this to happen? -# This is recommended, as it will improve the suggestions - Data will never be shared -save_preferences = true - -[System Settings] -# Do not play with these settings unless you know what you are doing -# Dev Mode allows a safe way to modify these settings!! -version = 3.6.0 -files = "bluetooth_details.py, bluetooth_logger.py, browser_miner.ps1, cmd_commands.py, config.ini, dir_list.py, dump_memory.py, encrypted_drive_audit.py, event_log.py, Logicytics.py, log_miner.py, media_backup.py, netadapter.ps1, network_psutil.py, packet_sniffer.py, property_scraper.ps1, registry.py, sensitive_data_miner.py, ssh_miner.py, sys_internal.py, tasklist.py, tree.ps1, usb_history.py, vulnscan.py, wifi_stealer.py, window_feature_miner.ps1, wmic.py, logicytics\Checks.py, logicytics\Config.py, logicytics\Execute.py, logicytics\FileManagement.py, logicytics\Flag.py, logicytics\Get.py, logicytics\Logger.py, logicytics\User_History.json.gz, vulnscan\Model_SenseMacro.4n1.pth" -# If you forked the project, change the USERNAME to your own to use your own fork as update material, -# I dont advise doing this however -config_url = https://raw.githubusercontent.com/DefinetlyNotAI/Logicytics/main/CODE/config.ini - -######################################################## -# The following settings are for specific modules # -######################################################## - -[Flag Settings] -# The minimum accuracy to suggest a flag, -# This is a percentage, and must be a float -# The default is 30.0, and is what we advise -# If the accuracy is below this, the flag will move to the next suggestion process -# The process is: difflib, then model, then history suggestions -# Make sure to keep between 0.0 and 100.0 -accuracy_min = 30.0 - -# This is the model to use to suggest flags, -# I advise to keep it as all-MiniLM-L6-v2 -# This is the best model for this task, and is lightweight -# The model MUST be a Sentence Transformer model -model_to_use = all-MiniLM-L6-v2 - -# Finally, should debug mode be enabled for the flag module? -# This will print out more information to the console, -# This is for the model itself, and is based on tqdm, it shows extra info on batches -# As well as more information on behind the scenes -model_debug = false - -################################################### - -[DumpMemory Settings] -# If the file size generated exceeds this limit, -# the file will be truncated with a message -# Put 0 to disable the limit - Limit is in MiB - int -file_size_limit = 0 -# Safety margin to check, it multiplies with the size limit -# This makes sure that after the file is created, there is still -# disk space left for other tasks, -# Make sure its above 1 or else it will fail -# Put 1 to disable the limit - Limit is in MiB - float -file_size_safety = 1.5 - -################################################### - -[NetWorkPsutil Settings] -# Total time this will take will be `sample_count * interval` - -# Number of samples to take for feature `measure network bandwidth usage` -# This is an integer, and should be 1 and above -sample_count = 5 -# Time between samples in seconds for feature `measure network bandwidth usage` -# This is a float, and should be above 0 -interval = 1.5 - -################################################### - -[PacketSniffer Settings] -# The interface to sniff packets on, keep it as WiFi for most cases -# Autocorrects between WiFi and Wi-Fi -interface = WiFi -# The number of packets to sniff, -# Must be greater than or equal to 1 - int -packet_count = 5000 -# The time to timeout the sniffing process only, -# Must be greater than or equal to 5 - int -timeout = 10 -# The maximum retry time for the whole process, -# Must be greater than or equal to 10 and timeout - int -max_retry_time = 30 - -################################################### - -[VulnScan Settings] -# Max characters of text from each file to analyze. Set an integer or None to disable truncation. -text_char_limit = None -# Max workers to be used, either integer or use "auto" to make it decide the best value -max_workers = auto -# Sensitivity threshold (0.0–1.0) for the model to flag content as sensitive -threshold = 0.6 -# Paths for required files -model = vulnscan/Model_SenseMacro.4n1.pth - -################################################## diff --git a/CODE/dir_list.py b/CODE/dir_list.py deleted file mode 100644 index c4495b79..00000000 --- a/CODE/dir_list.py +++ /dev/null @@ -1,70 +0,0 @@ -import os -from concurrent.futures import ThreadPoolExecutor - -from logicytics import log, execute - - -def run_command_threaded(directory: str, file: str, message: str, encoding: str = "UTF-8") -> None: - """ - Executes a PowerShell command to recursively list directory contents and writes the output to a specified file. - - Args: - directory (str): The target directory path to list contents from. - file (str): The output file path where directory contents will be appended. - message (str): A descriptive message for logging the operation. - encoding (str, optional): File writing encoding. Defaults to "UTF-8". - - Raises: - Exception: If command execution or file writing fails. - - Notes: - - Uses PowerShell's Get-ChildItem with recursive flag - - Appends output to the specified file - - Logs operation start and result/error - """ - log.info(f"Executing {message} for {directory}") - try: - safe_directory = directory.replace('"', '`"') # Escape quotes - command = f'powershell -NoProfile -Command "Get-ChildItem \\""{safe_directory}\\"" -Recurse"' - output = execute.command(command) - open(file, "a", encoding=encoding).write(output) - log.info(f"{message} Successful for {directory} - {file}") - except Exception as e: - log.error(f"Error while getting {message} for {directory}: {e}") - - -@log.function -def command_threaded(base_directory: str, file: str, message: str, encoding: str = "UTF-8") -> None: - """ - Concurrently lists contents of subdirectories within a base directory using thread pooling. - - Args: - base_directory (str): Root directory to explore and list subdirectories from. - file (str): Output file path to write directory listing results. - message (str): Descriptive logging message for the operation. - encoding (str, optional): File writing character encoding. Defaults to "UTF-8". - - Raises: - Exception: Logs and captures any errors during thread pool execution. - - Notes: - - Uses ThreadPoolExecutor for parallel directory content listing - - Processes each subdirectory concurrently - - Writes results to the specified file - - Handles potential errors during thread execution - """ - try: - with ThreadPoolExecutor(max_workers=min(32, os.cpu_count() * 4)) as executor: - subdirectories = [os.path.join(base_directory, d) for d in os.listdir(base_directory) if - os.path.isdir(os.path.join(base_directory, d))] - futures = [executor.submit(run_command_threaded, subdir, file, message, encoding) for subdir in - subdirectories] - for future in futures: - future.result() - except Exception as e: - log.error(f"Thread Pool Error: {e}") - - -if __name__ == "__main__": - log.warning("Running dir_list.py - This is very slow - We will use threading to speed it up") - command_threaded("C:\\", "Dir_Root.txt", "Root Directory Listing") diff --git a/CODE/dump_memory.py b/CODE/dump_memory.py deleted file mode 100644 index 360a27d8..00000000 --- a/CODE/dump_memory.py +++ /dev/null @@ -1,173 +0,0 @@ -import os -import platform -import struct -from datetime import datetime - -import psutil - -from logicytics import log, config - -# Constants from config with validation -LIMIT_FILE_SIZE = config.getint("DumpMemory Settings", "file_size_limit") # MiB -SAFETY_MARGIN = config.getfloat("DumpMemory Settings", "file_size_safety") # MiB -DUMP_DIR = config.get("DumpMemory Settings", "dump_directory", fallback="memory_dumps") - -if SAFETY_MARGIN < 1: - log.critical("Invalid Safety Margin Inputted - Cannot proceed with dump memory") - exit(1) - - -def capture_ram_snapshot(): - """ - Captures and logs the current system memory statistics to a file. - - Retrieves detailed information about RAM and swap memory usage using psutil. - Writes memory statistics in gigabytes to 'Ram_Snapshot.txt', including: - - Total RAM - - Used RAM - - Available RAM - - Total Swap memory - - Used Swap memory - - Free Swap memory - - Percentage of RAM used - - Logs the process and handles potential file writing errors. - - Raises: - IOError: If unable to write to the output file - Exception: For any unexpected errors during memory snapshot capture - """ - - def memory_helper(mem_var, flavor_text: str, use_free_rather_than_available: bool = False): - file.write(f"Total {flavor_text}: {mem_var.total / (1024 ** 3):.2f} GB\n") - file.write(f"Used {flavor_text}: {mem_var.used / (1024 ** 3):.2f} GB\n") - if use_free_rather_than_available: - file.write(f"Available {flavor_text}: {mem_var.free / (1024 ** 3):.2f} GB\n") - else: - file.write(f"Available {flavor_text}: {mem_var.available / (1024 ** 3):.2f} GB\n") - file.write(f"{flavor_text} Percent Usage: {mem_var.percent:.2f}%\n") - - log.info("Capturing RAM Snapshot...") - try: - memory = psutil.virtual_memory() - swap = psutil.swap_memory() - with open(os.path.join(DUMP_DIR, "Ram_Snapshot.txt"), "w", encoding="utf-8") as file: - memory_helper(memory, "RAM") - memory_helper(swap, "Swap Memory", use_free_rather_than_available=True) - except Exception as e: - log.error(f"Failed to capture RAM snapshot: {e}") - log.info("RAM Snapshot saved to Ram_Snapshot.txt") - - -def gather_system_info(): - """ - Gathers detailed system information and saves it to a file. - """ - log.info("Gathering system information...") - try: - sys_info = { - 'Architecture': platform.architecture(), - 'System': platform.system(), - 'Machine': platform.machine(), - 'Processor': platform.processor(), - 'Page Size (bytes)': struct.calcsize("P"), - 'CPU Count': psutil.cpu_count(), - 'CPU Frequency': psutil.cpu_freq().current if psutil.cpu_freq() else 'Unavailable', - 'Boot Time': datetime.fromtimestamp(psutil.boot_time()).strftime('%Y-%m-%d %H:%M:%S'), - } - except Exception as e: - log.error(f"Error gathering system information: {e}") - sys_info = {'Error': 'Failed to gather system information'} - try: - with open(os.path.join(DUMP_DIR, "SystemRam_Info.txt"), "w", encoding="utf-8") as file: - for key, value in sys_info.items(): - file.write(f"{key}: {value}\n") - except Exception as e: - log.error(f"Error writing system info to file: {e}") - log.info("System Information saved to SystemRam_Info.txt") - - -# Memory Dump -def memory_dump(): - """ - Performs a memory dump of the current process and saves it to a file. - """ - log.info("Creating basic memory dump scan...") - pid = os.getpid() - - try: - process = psutil.Process(pid) - dump_path = os.path.join(DUMP_DIR, "Ram_Dump.txt") - with open(dump_path, "wb") as dump_file: - total_size = 0 - - # Disk space safety check - required_space = LIMIT_FILE_SIZE * 1024 * 1024 * SAFETY_MARGIN - free_space = psutil.disk_usage(DUMP_DIR).free - if free_space < required_space: - log.error(f"Not enough disk space. Need at least {required_space / (1024 ** 2):.2f} MiB") - return - - for mem_region in process.memory_maps(grouped=False): - if 'r' not in mem_region.perms: - continue - - try: - start, end = (int(addr, 16) for addr in mem_region.addr.split('-')) \ - if '-' in mem_region.addr else (int(mem_region.addr, 16), - int(mem_region.addr, 16) + mem_region.rss) - except Exception as e: - log.warning(f"Invalid address format '{mem_region.addr}': {e}") - continue - - region_metadata = { - ' Start Address': hex(start), - ' End Address': hex(end), - ' Region Size (bytes)': end - start, - ' RSS (bytes)': mem_region.rss, - ' Permissions': mem_region.perms, - ' Path': mem_region.path, - ' Index': mem_region.index, - } - - try: - metadata_str = "Memory Region Metadata:\n" + "\n".join( - f"{key}: {value}" for key, value in region_metadata.items()) + "\n\n" - metadata_bytes = metadata_str.encode() - if (total_size + len(metadata_bytes) > LIMIT_FILE_SIZE * 1024 * 1024) and (LIMIT_FILE_SIZE != 0): - dump_file.write(f"Truncated: file exceeded {LIMIT_FILE_SIZE} MiB limit.\n".encode()) - break - dump_file.write(metadata_bytes) - total_size += len(metadata_bytes) - except Exception as e: - log.error(f"Error writing memory region metadata: {e}") - - except psutil.Error as e: - log.error(f"Error accessing process memory: {e}") - except Exception as e: - log.error(f"General memory dump error: {e}") - - log.info("Memory scan saved to Ram_Dump.txt") - - -# Main function to run all tasks -@log.function -def main(): - """ - Executes all memory diagnostics and collection routines. - """ - try: - os.makedirs(DUMP_DIR, exist_ok=True) - except Exception as e: - log.critical(f"Failed to create dump directory '{DUMP_DIR}': {e}") - return - - log.info("Starting system memory collection tasks...") - capture_ram_snapshot() - gather_system_info() - memory_dump() - log.info("All tasks completed [dump_memory.py].") - - -if __name__ == "__main__": - main() diff --git a/CODE/encrypted_drive_audit.py b/CODE/encrypted_drive_audit.py deleted file mode 100644 index 290281bf..00000000 --- a/CODE/encrypted_drive_audit.py +++ /dev/null @@ -1,106 +0,0 @@ -import datetime -import getpass -import os -import platform -import shutil -import subprocess -from pathlib import Path - -from logicytics import check, log - - -def now_iso(): - return datetime.datetime.now().astimezone().isoformat() - - -def run_cmd(cmd): - log.debug(f"Running command: {cmd}") - try: - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) - if proc.returncode == 0: - log.debug(f"Command succeeded: {cmd}") - else: - log.warning(f"Command returned {proc.returncode}: {cmd}") - return proc.stdout.strip(), proc.stderr.strip(), proc.returncode - except FileNotFoundError: - log.error(f"Command not found: {cmd[0]}") - return "", "not found", 127 - except subprocess.TimeoutExpired: - log.error(f"Command timed out: {cmd}") - return "", "timeout", 124 - - -def have(cmd_name): - exists = shutil.which(cmd_name) is not None - log.debug(f"Check if '{cmd_name}' exists: {exists}") - return exists - - -def get_mountvol_output(): - log.info("Gathering mounted volumes via mountvol") - out, err, _ = run_cmd(["mountvol"]) - if not out: - return err - lines = out.splitlines() - filtered = [] - keep = False - for line in lines: - if line.strip().startswith("\\\\?\\Volume"): - keep = True - if keep: - filtered.append(line) - return "\n".join(filtered) - - -def main(): - script_dir = Path(__file__).resolve().parent - report_path = script_dir / "win_encrypted_volume_report.txt" - log.info(f"Starting encrypted volume analysis, report will be saved to {report_path}") - - with report_path.open("w", encoding="utf-8") as f: - f.write("=" * 80 + "\n") - f.write("Windows Encrypted Volume Report\n") - f.write("=" * 80 + "\n") - f.write(f"Generated at: {now_iso()}\n") - f.write(f"User: {getpass.getuser()}\n") - f.write(f"IsAdmin: {check.admin()}\n") - f.write(f"Hostname: {platform.node()}\n") - f.write(f"Version: {platform.platform()}\n\n") - - # Logical drives - log.info("Gathering logical volumes via wmic") - f.write("Logical Volumes (wmic):\n") - out, err, _ = run_cmd(["wmic", "logicaldisk", "get", - "DeviceID,DriveType,FileSystem,FreeSpace,Size,VolumeName"]) - f.write(out + "\n" + err + "\n\n") - - # Mounted volumes - f.write("Mounted Volumes (mountvol):\n") - f.write(get_mountvol_output() + "\n\n") - - # BitLocker status - f.write("=" * 80 + "\nBitLocker Status\n" + "=" * 80 + "\n") - if have("manage-bde"): - log.info("Checking BitLocker status with manage-bde") - for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ": - path = f"{letter}:" - if os.path.exists(f"{path}\\"): - out, err, _ = run_cmd(["manage-bde", "-status", path]) - f.write(f"Drive {path}:\n{out}\n{err}\n\n") - else: - log.warning("manage-bde not found") - - if have("powershell"): - log.info("Checking BitLocker status with PowerShell") - f.write("PowerShell Get-BitLockerVolume:\n") - ps_cmd = r"Get-BitLockerVolume | Format-List *" - out, err, _ = run_cmd(["powershell", "-NoProfile", "-Command", ps_cmd]) - f.write(out + "\n" + err + "\n\n") - else: - log.warning("PowerShell not available") - - log.info(f"Report successfully saved to {report_path}") - - -if __name__ == "__main__": - main() diff --git a/CODE/event_log.py b/CODE/event_log.py deleted file mode 100644 index a27d561c..00000000 --- a/CODE/event_log.py +++ /dev/null @@ -1,83 +0,0 @@ -import os -import shutil -import threading - -import wmi # Import the wmi library - -from logicytics import log - - -@log.function -def parse_event_logs(log_type: str, output_file: str): - """ - Parses Windows event logs of a specified type and writes them to an output file using WMI. - - Args: - log_type (str): The type of event log to parse (e.g., 'Security', 'Application', 'System'). - output_file (str): The file path where the parsed event logs will be written. - - Raises: - wmi.x_wmi: If there is a WMI-specific error during event log retrieval. - Exception: If there is a general error during file operations or log parsing. - - Notes: - - Requires administrative privileges to access Windows event logs. - - Retrieves all events for the specified log type using a WMI query. - - Writes event details including category, timestamp, source, event ID, type, and data. - - Logs informational and debug messages during the parsing process. - """ - log.info(f"Parsing {log_type} events (Windows Events) and writing to {output_file}, this may take a while...") - try: - # Initialize WMI connection - c = wmi.WMI() - - # Query based on log_type ('Security', 'Application', or 'System') - query = f"SELECT * FROM Win32_NTLogEvent WHERE Logfile = '{log_type}'" - log.debug(f"Executing WMI query: {query}") - - # Open the output file for writing - with open(output_file, 'w') as f: - events = c.query(query) - f.write(f"Total records: {len(events)}\n\n") - log.debug(f"Number of events retrieved: {len(events)}") - for event in events: - event_data = { - 'Event Category': event.Category, - 'Time Generated': event.TimeGenerated, - 'Source Name': event.SourceName, - 'Event ID': event.EventCode, - 'Event Type': event.Type, - 'Event Data': event.InsertionStrings - } - f.write(str(event_data) + '\n\n') - - log.info(f"{log_type} events (Windows Events) have been written to {output_file}") - except wmi.x_wmi as err: - log.error(f"Error opening or reading the event log: {err}") - except Exception as err: - log.error(f"Fatal issue: {err}") - - -if __name__ == "__main__": - try: - if os.path.exists('event_logs'): - shutil.rmtree('event_logs') - os.mkdir('event_logs') - except Exception as e: - log.error(f"Fatal issue: {e}") - exit(1) - - threads = [] - threads_items = [('Security', 'event_logs/Security_events.txt'), - ('Application', 'event_logs/App_events.txt'), - ('System', 'event_logs/System_events.txt')] - - for log_type_main, output_file_main in threads_items: - thread = threading.Thread(target=parse_event_logs, args=(log_type_main, output_file_main)) - thread.daemon = True # Don't hang if main thread exits - threads.append(thread) - thread.start() - for thread in threads: - thread.join(timeout=600) # Wait max 10 minutes per thread - if thread.is_alive(): - log.error(f"Thread for {thread.name} timed out (10 minutes)!") diff --git a/CODE/log_miner.py b/CODE/log_miner.py deleted file mode 100644 index eff74f47..00000000 --- a/CODE/log_miner.py +++ /dev/null @@ -1,54 +0,0 @@ -import subprocess - -from logicytics import log - - -@log.function -def backup_windows_logs(): - """ - Backs up Windows system logs to a CSV file using PowerShell. - - This function retrieves system logs and exports them to a CSV file named 'Logs_backup.csv'. - It uses PowerShell's Get-EventLog cmdlet to collect system logs and Export-Csv to save them. - - The function handles potential errors during log backup and logs the operation's outcome. - If the backup fails, an error message is logged without raising an exception. - - Returns: - None - - Raises: - No explicit exceptions are raised; errors are logged instead. - - Example: - When called, this function will create a 'Logs_backup.csv' file - containing all system event log entries. - """ - try: - log_type = "System" - backup_file = "Logs_backup.csv" - # Construct the PowerShell command as a single string - cmd = f'Get-EventLog -LogName "{log_type}" | Export-Csv -Path "{backup_file}" -NoTypeInformation' - - # Use subprocess.Popen to Execute the PowerShell command - process = subprocess.Popen( - ["powershell.exe", "-Command", cmd], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - stdout, stderr = process.communicate(input=cmd) - - if process.returncode != 0: - log.error(f"Failed to backup logs: {stderr.strip()}") - - log.info(f"Windows logs backed up to {backup_file}") - except Exception as e: - log.error(f"Failed to backup logs: {str(e)}") - - log.info("Log Miner completed.") - - -if __name__ == "__main__": - backup_windows_logs() diff --git a/CODE/logicytics/Checks.py b/CODE/logicytics/Checks.py deleted file mode 100644 index e3ad3c9d..00000000 --- a/CODE/logicytics/Checks.py +++ /dev/null @@ -1,92 +0,0 @@ -from __future__ import annotations - -import ctypes -import os.path -import subprocess -import zipfile - -from logicytics.Execute import Execute - - -class Check: - @staticmethod - def admin() -> bool: - """ - Check if the current user has administrative privileges. - - Returns: - bool: True if the user is an admin, False otherwise. - """ - try: - return ctypes.windll.shell32.IsUserAnAdmin() - except AttributeError: - return False - - @staticmethod - def execution_policy() -> bool: - """ - Check if PowerShell execution policy is set to unrestricted. - - Returns: - bool: True if execution policy is unrestricted, False otherwise. - - Note: - This method requires PowerShell to be available on the system. - """ - try: - result = subprocess.run( - ["powershell", "-Command", "Get-ExecutionPolicy"], - capture_output=True, - text=True, - timeout=5 # Don't hang forever - ) - return result.returncode == 0 and result.stdout.strip().lower() == "unrestricted" - except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e: - exit(f"Failed to check execution policy: {e}") - - @staticmethod - def uac() -> bool: - """ - Check if User Account Control (UAC) is enabled on the system. - - This function runs a PowerShell command to retrieve the value of the EnableLUA registry key, - which indicates whether UAC is enabled. It then returns True if UAC is enabled, False otherwise. - - Returns: - bool: True if UAC is enabled, False otherwise. - """ - value = Execute.command( - r"powershell (Get-ItemProperty HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System).EnableLUA" - ) - return int(value.strip("\n")) == 1 - - @staticmethod - def sys_internal_zip() -> str: - """ - Extracts the SysInternal_Suite zip file if it exists and is not ignored. - - This function checks if the SysInternal_Suite zip file exists and if it is not ignored. - If the zip file exists and is not ignored, - it extracts its contents to the SysInternal_Suite directory. - If the zip file is ignored, it prints a message indicating that it is skipping the extraction. - - Raises: - Exception: If there is an error during the extraction process. The error message is printed to the console and the program exits. - """ - try: - ignore_file = os.path.exists("../SysInternal_Suite/.sys.ignore") - zip_file = os.path.exists("../SysInternal_Suite/SysInternal_Suite.zip") - - if zip_file and not ignore_file: - with zipfile.ZipFile( - "../SysInternal_Suite/SysInternal_Suite.zip" - ) as zip_ref: - zip_ref.extractall("SysInternal_Suite") - return "SysInternal_Suite zip extracted" - - elif ignore_file: - return "Found .sys.ignore file, skipping SysInternal_Suite zip extraction" - - return None - except Exception as err: - exit(f"Failed to unzip SysInternal_Suite: {err}") diff --git a/CODE/logicytics/Config.py b/CODE/logicytics/Config.py deleted file mode 100644 index 6e0bc187..00000000 --- a/CODE/logicytics/Config.py +++ /dev/null @@ -1,48 +0,0 @@ -import configparser -import os - - -def __config_data() -> tuple[str, str, list[str], bool, str]: - """ - Retrieves configuration data from the 'config.ini' file. - - If the configuration file is not found in any of these locations, - the program exits with an error message. - - Returns: - tuple[str, str, list[str], bool]: A tuple containing: - - Log level (str): Either "DEBUG" or "INFO" - - Version (str): System version from configuration - - Files (list[str]): List of files specified in configuration - - Delete old logs (bool): Flag indicating whether to delete old log files - - config itself - - Raises: - SystemExit: If the 'config.ini' file cannot be found in any of the attempted locations - """ - - def _config_path() -> str: - configs_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.ini") - - if os.path.exists(configs_path): - return configs_path - exit("The config.ini file is not found in the expected location.") - - config_local = configparser.ConfigParser() - path = _config_path() - config_local.read(path) - - log_using_debug = config_local.getboolean("Settings", "log_using_debug") - delete_old_logs = config_local.getboolean("Settings", "delete_old_logs") - version = config_local.get("System Settings", "version") - files = config_local.get("System Settings", "files").split(", ") - - log_using_debug = "DEBUG" if log_using_debug else "INFO" - - return log_using_debug, version, files, delete_old_logs, config_local - - -# Check if the script is being run directly, if not, set up the library -if __name__ == '__main__': - exit("This is a library, Please import rather than directly run.") -DEBUG, VERSION, CURRENT_FILES, DELETE_LOGS, config = __config_data() diff --git a/CODE/logicytics/Execute.py b/CODE/logicytics/Execute.py deleted file mode 100644 index 85de3f8b..00000000 --- a/CODE/logicytics/Execute.py +++ /dev/null @@ -1,96 +0,0 @@ -from __future__ import annotations - -import subprocess -from subprocess import CompletedProcess - - -class Execute: - @classmethod - def script(cls, script_path: str) -> list[tuple[str, str]] | None: - """ - Execute a script file based on its file extension. - - Executes Python and PowerShell scripts with different handling mechanisms. - For Python scripts, runs the script and returns None. - For PowerShell scripts, first unblocks the script and then executes it, - returning a list of message-ID pairs. - - Parameters: - script_path (str): Path to the script file to be executed. - - Returns: - list[list[str]] | None: A list of message-ID pairs for PowerShell scripts, - or None for Python scripts. - - Raises: - Potential subprocess-related exceptions during script execution. - """ - if script_path.endswith(".py"): - cls.__run_python_script(script_path) - return None - else: - if script_path.endswith(".ps1"): - cls.__unblock_ps1_script(script_path) - return cls.__run_other_script(script_path) - - @staticmethod - def command(command: str) -> str: - """ - Runs a command in a subprocess and returns the output as a string. - - Parameters: - command (str): The command to be executed. - - Returns: - CompletedProcess.stdout: The output of the command as a string. - """ - process = subprocess.run(command, capture_output=True, text=True) - return process.stdout - - @staticmethod - def __unblock_ps1_script(script: str): - """ - Unblocks and runs a PowerShell (.ps1) script. - Parameters: - script (str): The path of the PowerShell script. - Returns: - None - """ - try: - unblock_command = f'powershell.exe -Command "Unblock-File -Path {script}"' - subprocess.run(unblock_command, shell=False, check=True) - except Exception as err: - exit(f"Failed to unblock script: {err}") - - @staticmethod - def __run_python_script(script: str): - """ - Runs a Python (.py) script. - Parameters: - script (str): The path of the Python script. - Returns: - None - """ - result = subprocess.Popen( - ["python", script], stdout=subprocess.PIPE - ).communicate()[0] - # LEAVE AS PRINT - print(result.decode()) - - @classmethod - def __run_other_script(cls, script: str) -> list[list[str]]: - """ - Runs a script with other extensions and logs output based on its content. - Parameters: - script (str): The path of the script. - Returns: - None - """ - result = cls.command(f"powershell.exe -File {script}") - lines = result.splitlines() - messages = [] - for line in lines: - if ":" in line: - id_part, message_part = line.split(":", 1) - messages.append([message_part.strip(), id_part.strip()]) - return messages diff --git a/CODE/logicytics/FileManagement.py b/CODE/logicytics/FileManagement.py deleted file mode 100644 index a2633feb..00000000 --- a/CODE/logicytics/FileManagement.py +++ /dev/null @@ -1,230 +0,0 @@ -from __future__ import annotations -from __future__ import annotations - -import hashlib -import os.path -import shutil -import subprocess -import zipfile -from datetime import datetime - - -class FileManagement: - @staticmethod - def open_file(file: str, use_full_path: bool = False) -> str | None: - """ - Opens a specified file using its default application in a cross-platform manner. - Args: - file (str): The path to the file to be opened. - use_full_path (bool): Whether to use the full path of the file or not. - Returns: - None - """ - if not file == "": - if use_full_path: - current_dir = os.path.dirname(os.path.abspath(__file__)) - file_path = os.path.join(current_dir, file) - else: - file_path = os.path.realpath(file) - try: - subprocess.run(["start", file_path], shell=False) - except Exception as e: - return f"Error opening file: {e}" - return None - - @staticmethod - def mkdir(): - """ - Creates the necessary directories for storing logs, and data. - - This method ensures the existence of specific directory structures used by the application, including: - - Log directories for general, debug, and performance logs - - Data directories for storing hashes and zip files - - The method uses `os.makedirs()` with `exist_ok=True` to create directories without raising an error if they already exist. - - Returns: - None: No return value. Directories are created as a side effect. - """ - os.makedirs("../ACCESS/LOGS/", exist_ok=True) - os.makedirs("../ACCESS/LOGS/DEBUG", exist_ok=True) - os.makedirs("../ACCESS/LOGS/PERFORMANCE", exist_ok=True) - os.makedirs("../ACCESS/DATA/Hashes", exist_ok=True) - os.makedirs("../ACCESS/DATA/Zip", exist_ok=True) - - class Zip: - """ - A class to handle zipping files, generating SHA256 hashes, and moving files. - - Methods: - __get_files_to_zip(path: str) -> list: - Returns a list of files to be zipped, excluding certain file types and names. - - __create_zip_file(path: str, files: list, filename: str): - Creates a zip file from the given list of files. - - __remove_files(path: str, files: list): - Removes the specified files from the given path. - - __generate_sha256_hash(filename: str) -> str: - Generates a SHA256 hash for the specified zip file. - - __write_hash_to_file(filename: str, sha256_hash: str): - Writes the SHA256 hash to a file. - - __move_files(filename: str): - Moves the zip file and its hash file to designated directories. - - and_hash(cls, path: str, name: str, flag: str) -> tuple | str: - Zips files, generates a SHA256 hash, and moves the files. - """ - - @staticmethod - def __get_files_to_zip(path: str) -> list: - """ - Returns a list of files and directories to be zipped, excluding certain file types and names. - - Args: - path (str): The directory path to search for files. - - Returns: - list: A list of file and directory names to be zipped. - """ - excluded_extensions = (".py", ".exe", ".bat", ".ps1", ".pkl", ".pth") - excluded_prefixes = ( - "config.ini", - "SysInternal_Suite", - "__pycache__", - "logicytics", - "vulnscan", - ) - - return [ - f - for f in os.listdir(path) - if not f.endswith(excluded_extensions) - and not f.startswith(excluded_prefixes) - ] - - @staticmethod - def __create_zip_file(path: str, files: list, filename: str): - """ - Creates a zip file from the given list of files. - - Args: - path (str): The directory path containing the files. - files (list): A list of file names to be zipped. - filename (str): The name of the output zip file. - - Returns: - None - """ - - def ignore_files(files_func): - for root, _, file_func in os.walk(os.path.join(path, files_func)): - for f in file_func: - zip_file.write( - os.path.join(root, f), - os.path.relpath(os.path.join(root, f), path), - ) - - with zipfile.ZipFile(f"{filename}.zip", "w") as zip_file: - for file in files: - if os.path.isdir(os.path.join(path, file)): - ignore_files(file) - else: - zip_file.write(os.path.join(path, file)) - - @staticmethod - def __remove_files(path: str, files: list) -> str | None: - """ - Removes the specified files from the given path. - - Args: - path (str): The directory path containing the files. - files (list): A list of file names to be removed. - - Returns: - None or str: Returns an error message if an exception occurs. - """ - for file in files: - try: - shutil.rmtree(os.path.join(path, file)) - except OSError: - os.remove(os.path.join(path, file)) - except Exception as e: - return f"Error: {e}" - return None - - @staticmethod - def __generate_sha256_hash(filename: str) -> str: - """ - Generates a SHA256 hash for the specified zip file. - - Args: - filename (str): The name of the zip file. - - Returns: - str: The SHA256 hash of the zip file. - """ - with open(f"{filename}.zip", "rb") as zip_file: - zip_data = zip_file.read() - return hashlib.sha256(zip_data).hexdigest() - - @staticmethod - def __write_hash_to_file(filename: str, sha256_hash: str): - """ - Writes the SHA256 hash to a file. - - Args: - filename (str): The name of the hash file. - sha256_hash (str): The SHA256 hash to be written. - - Returns: - None - """ - with open(f"{filename}.hash", "w") as hash_file: - hash_file.write(sha256_hash) - - @staticmethod - def __move_files(filename: str): - """ - Moves the zip file and its hash file to designated directories. - - Args: - filename (str): The name of the files to be moved. - - Returns: - None - """ - shutil.move(f"{filename}.zip", "../ACCESS/DATA/Zip") - shutil.move(f"{filename}.hash", "../ACCESS/DATA/Hashes") - - @classmethod - def and_hash(cls, path: str, name: str, flag: str) -> tuple | str: - """ - Zips files, generates a SHA256 hash, and moves the files. - - Args: - path (str): The directory path containing the files. - name (str): The base name for the output files. - flag (str): A flag to be included in the output file names. - - Returns: - tuple or str: A tuple containing success messages or an error message. - """ - time = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - filename = f"Logicytics_{name}_{flag}_{time}" - files_to_zip = cls.__get_files_to_zip(path) - cls.__create_zip_file(path, files_to_zip, filename) - check = cls.__remove_files(path, files_to_zip) - if isinstance(check, str): - return check - else: - sha256_hash = cls.__generate_sha256_hash(filename) - cls.__write_hash_to_file(filename, sha256_hash) - cls.__move_files(filename) - return ( - f"Zip file moved to ../ACCESS/DATA/Zip/{filename}.zip", - f"SHA256 Hash file moved to ../ACCESS/DATA/Hashes/{filename}.hash", - ) diff --git a/CODE/logicytics/Flag.py b/CODE/logicytics/Flag.py deleted file mode 100644 index 0a4367f0..00000000 --- a/CODE/logicytics/Flag.py +++ /dev/null @@ -1,820 +0,0 @@ -from __future__ import annotations - -import argparse -import difflib -import gzip -import json -import os -from collections import Counter -from datetime import datetime - -from logicytics.Config import config -from logicytics.Logger import log - -# Check if the script is being run directly, if not, set up the library -if __name__ == "__main__": - exit("This is a library, Please import rather than directly run.") -else: - # Save user preferences? - SAVE_PREFERENCES = config.getboolean("Settings", "save_preferences") - # Debug mode for Sentence Transformer - DEBUG_MODE = config.getboolean( - "Flag Settings", "model_debug" - ) # Debug mode for Sentence Transformer - # File for storing user history data - HISTORY_FILE = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "User_History.json.gz" - ) # User history file - # Minimum accuracy threshold for flag suggestions - MIN_ACCURACY_THRESHOLD = float( - config.get("Flag Settings", "accuracy_min") - ) # Minimum accuracy threshold for flag suggestions - if not 1 <= MIN_ACCURACY_THRESHOLD <= 99: - raise ValueError("accuracy_min must be between 1 and 99") - - -class Flag: - class Match: - @staticmethod - def __get_sim(user_input: str, all_descriptions: list[str]) -> list[float]: - """ - Compute cosine similarity between user input and flag descriptions using a Sentence Transformer model. - - This method encodes the user input and historical flag descriptions into embeddings and calculates their cosine similarities. It handles model loading, logging configuration, and error handling for the embedding process. - - Parameters: - user_input (str): The current user input to match against historical descriptions - all_descriptions (list[str]): A list of historical flag descriptions to compare - - Returns: - list[float]: A list of similarity scores between the user input and each historical description - - Raises: - SystemExit: If there is an error loading the specified Sentence Transformer model - - Notes: - - Uses the model specified in the configuration file - - Configures logging based on the global DEBUG_MODE setting - - Converts embeddings to tensors for efficient similarity computation - """ - # Encode the current user input and historical inputs - from sentence_transformers import SentenceTransformer, util - import logging # Suppress logging messages from Sentence Transformer due to verbosity - - # Set the logging level based on the debug mode, either DEBUG or ERROR (aka only important messages) - if DEBUG_MODE: - logging.getLogger("sentence_transformers").setLevel(logging.DEBUG) - else: - logging.getLogger("sentence_transformers").setLevel(logging.ERROR) - - try: - MODEL = SentenceTransformer(config.get("Flag Settings", "model_to_use")) - except Exception as e: - log.critical(f"Error: {e}") - log.error("Please check the model name in the config file.") - log.error( - f"Model name {config.get('Flag Settings', 'model_to_use')} may not be valid." - ) - exit(1) - - user_embedding = MODEL.encode( - user_input, convert_to_tensor=True, show_progress_bar=DEBUG_MODE - ) - historical_embeddings = MODEL.encode( - all_descriptions, convert_to_tensor=True, show_progress_bar=DEBUG_MODE - ) - - # Compute cosine similarities - similarities = ( - util.pytorch_cos_sim(user_embedding, historical_embeddings) - .squeeze(0) - .tolist() - ) - return similarities - - @classmethod - def __suggest_flags_based_on_history(cls, user_input: str) -> list[str]: - """ - Suggests flags based on historical data and similarity to the current input. - - This method analyzes historical user interactions to recommend relevant flags when preferences for saving history are enabled. It uses semantic similarity to find the most contextually related flags from past interactions. - - Parameters: - user_input (str): The current input for which suggestions are needed. - - Returns: - list[str]: A list of suggested flags derived from historical interactions, filtered by similarity threshold. - - Notes: - - Returns an empty list if history saving is disabled or no interaction history exists - - Uses cosine similarity with a minimum threshold of 0.3 to filter suggestions - - Limits suggestions to top 3 most similar historical inputs - - Removes duplicate flag suggestions - """ - if not SAVE_PREFERENCES: - return [] - history_data = cls.load_history() - if not history_data or "interactions" not in history_data: - return [] - - interactions = history_data["interactions"] - all_descriptions = [] - all_flags = [] - - # Combine all flags and their respective user inputs - for flag, details in interactions.items(): - all_flags.extend([flag] * len(details)) - all_descriptions.extend([detail["user_input"] for detail in details]) - - # Encode the current user input and historical inputs - # Compute cosine similarities - similarities = cls.__get_sim(user_input, all_descriptions) - - # Find the top 3 most similar historical inputs - top_indices = sorted( - range(len(similarities)), key=lambda i: similarities[i], reverse=True - )[:3] - suggested_flags = [ - all_flags[i] for i in top_indices if similarities[i] > 0.3 - ] - - # Remove duplicates and return suggestions - return list(dict.fromkeys(suggested_flags)) - - @classmethod - def generate_summary_and_graph(cls): - """ - Generates a comprehensive summary and visualization of user interaction history with command-line flags. - - This method processes historical interaction data, computes statistical insights, and creates a bar graph representing flag usage frequency. It performs the following key tasks: - - Loads historical interaction data from a compressed file - - Calculates and prints detailed statistics for each flag - - Generates a horizontal bar graph of flag usage counts - - Saves the graph visualization to a PNG file - - Parameters: - cls (_Match): The class instance containing historical data methods - - Raises: - SystemExit: If no history data file is found - FileNotFoundError: If unable to save the graph in default locations - - Side Effects: - - Prints detailed interaction summary to console - - Saves flag usage graph as a PNG image - - Uses matplotlib to create visualization - - Notes: - - Currently in beta stage of development - - Requires matplotlib for graph generation - - Attempts to save graph in multiple predefined directory paths - """ - # Load the decompressed history data using the load_history function - import matplotlib.pyplot as plt - - if not os.path.exists(HISTORY_FILE): - exit("No history data found.") - - history_data = cls.load_history() - - # Extract interactions and flag usage count - interactions = history_data["interactions"] - flags_usage = history_data["flags_usage"] - - # Summary of flag usage - total_interactions = sum(flags_usage.values()) - - log.info( - "--------------------------------------------------\n Flag guessing statistics:\n --------------------------------------------------" - ) - - for flag, details in interactions.items(): - accuracies = [detail["accuracy"] for detail in details] - device_names = [detail["device_name"] for detail in details] - user_inputs = [detail["user_input"] for detail in details] - - average_accuracy = sum(accuracies) / len(accuracies) - most_common_device = Counter(device_names).most_common(1)[0][0] - average_user_input = Counter(user_inputs).most_common(1)[0][0] - log.info(f"""Flag: {flag} - Average Accuracy: {average_accuracy:.2f}% - Most Common Device Name: {most_common_device} - Most Common User Input: {average_user_input}""") - - # Print the summary to the console - log.info( - "--------------------------------------------------\n User Interaction Summary:\n --------------------------------------------------" - ) - - log.info( - f"Total Interactions with the match flag feature: {total_interactions}" - ) - flag_usage_summary = "\n".join( - [ - f" {flag}: {count} times" - for flag, count in flags_usage.items() - ] - ) - log.info(f"Flag Usage Summary:\n{flag_usage_summary}") - - # Generate the graph for flag usage - flags = list(flags_usage.keys()) - counts = list(flags_usage.values()) - - plt.figure(figsize=(10, 6)) - plt.barh(flags, counts, color="skyblue") - plt.xlabel("Usage Count") - plt.title("Flag Usage Frequency") - plt.gca().invert_yaxis() # Invert y-axis for better readability - plt.subplots_adjust( - left=0.2, right=0.8, top=0.9, bottom=0.1 - ) # Adjust layout - - # Save and display the graph - try: - plt.savefig("../ACCESS/DATA/Flag_usage_summary.png") - log.info( - "Flag Usage Summary Graph saved to 'ACCESS/DATA/Flag_usage_summary.png'" - ) - except FileNotFoundError: - try: - plt.savefig("../../ACCESS/DATA/Flag_usage_summary.png") - log.info( - "Flag Usage Summary Graph saved to 'ACCESS/DATA/Flag_usage_summary.png'" - ) - except FileNotFoundError: - plt.savefig("Flag_usage_summary.png") - log.info( - "Flag Usage Summary Graph saved in current working directory as 'Flag_usage_summary.png'" - ) - - @staticmethod - def load_history() -> dict: - """ - Load user interaction history from a gzipped JSON file. - - This method attempts to read and parse historical interaction data from a compressed JSON file. If the file is not found, it returns an empty history structure with an empty interactions dictionary and a zero-initialized flags usage counter. - - Returns: - dict: A dictionary containing: - - 'interactions': A dictionary of past user interactions - - 'flags_usage': A Counter object tracking flag usage frequencies - - Raises: - json.JSONDecodeError: If the JSON file is malformed - gzip.BadGzipFile: If the gzipped file is corrupted - """ - try: - with gzip.open( - HISTORY_FILE, "rt", encoding="utf-8" - ) as f: # Use 'rt' mode for text read - return json.load(f) - except FileNotFoundError: - return {"interactions": {}, "flags_usage": Counter()} - - @staticmethod - def save_history(history_data: dict): - """ - Save user interaction history to a gzipped JSON file. - - This method writes the user history to a compressed JSON file only if saving preferences are enabled. - The history is saved with an indentation of 4 spaces for readability. - - Parameters: - history_data (dict[str, any]): A dictionary containing user interaction history data to be saved. - - Notes: - - Saves only if SAVE_PREFERENCES is True - - Uses gzip compression to reduce file size - - Writes in UTF-8 encoding - - Indents JSON for human-readable format - """ - if SAVE_PREFERENCES: - with gzip.open( - HISTORY_FILE, "wt", encoding="utf-8" - ) as f: # Use 'wt' mode for text write - json.dump(history_data, f, indent=4) - - @classmethod - def update_history(cls, user_input: str, matched_flag: str, accuracy: float): - """ - Update the user interaction history with details of a matched flag. - - This method records user interactions with flags, including timestamp, input, match accuracy, - and device information. It only updates history if save preferences are enabled. - - Parameters: - user_input (str): The original input text provided by the user. - matched_flag (str): The flag that was successfully matched to the user input. - accuracy (float): The similarity/match accuracy score for the flag. - - Side Effects: - - Modifies the history JSON file by adding a new interaction entry - - Increments the usage count for the matched flag - - Requires write access to the history file - - Notes: - - Skips history update if SAVE_PREFERENCES is False - - Creates new flag entries in history if they do not exist - - Uses current timestamp and logged-in user's device name - """ - if not SAVE_PREFERENCES: - return - history_data = cls.load_history() - matched_flag = matched_flag.lstrip("-") - - # Ensure that interactions is a dictionary (not a list) - if not isinstance(history_data["interactions"], dict): - history_data["interactions"] = {} - - # Create a new interaction dictionary - interaction = { - "timestamp": datetime.now().strftime("%H:%M:%S - %d/%m/%Y"), - "user_input": user_input, - "accuracy": accuracy, - "device_name": os.getlogin(), - } - - # Ensure the flag exists in the interactions dictionary - if matched_flag not in history_data["interactions"]: - history_data["interactions"][matched_flag] = [] - - # Append the new interaction to the flag's list of interactions - history_data["interactions"][matched_flag].append(interaction) - - # Ensure the flag exists in the flags_usage counter and increment it - if matched_flag not in history_data["flags_usage"]: - history_data["flags_usage"][matched_flag] = 0 - history_data["flags_usage"][matched_flag] += 1 - - cls.save_history(history_data) - - @classmethod - def flag( - cls, user_input: str, flags: list[str], flag_description: list[str] - ) -> tuple[str, float]: - """ - Matches user input to flag descriptions using advanced semantic similarity. - - Computes the best matching flag based on cosine similarity between the user input and flag descriptions. - Handles matching with a minimum accuracy threshold and provides flag suggestions from historical data - if no direct match is found. - - Parameters: - user_input (str): The input string to match against available flags. - flags (list[str]): List of available command flags. - flag_description (list[str]): Corresponding descriptions for each flag. - - Returns: - tuple[str, float]: A tuple containing: - - The best matched flag (or 'Nothing matched') - - Accuracy percentage of the match (0.0-100.0) - - Raises: - ValueError: If the number of flags and descriptions do not match. - - Side Effects: - - Updates user interaction history - - Prints flag suggestions if no direct match is found - - Requires a global MIN_ACCURACY_THRESHOLD to be defined - - Example: - matched_flag, accuracy = Flag.flag("show help", - ["-h", "--verbose"], - ["Display help", "Enable verbose output"]) - """ - if len(flags) != len(flag_description): - raise ValueError( - "flags and flag_description lists must be of the same length" - ) - - # Combine flags and descriptions for better matching context - combined_descriptions = [ - f"{flag} {desc}" for flag, desc in zip(flags, flag_description) - ] - - # Encode user input and all descriptions - # Compute cosine similarities - similarities = cls.__get_sim(user_input, combined_descriptions) - - # Find the best match - best_index = max(range(len(similarities)), key=lambda i: similarities[i]) - best_accuracy = similarities[best_index] * 100 - best_match = ( - flags[best_index] - if best_accuracy > MIN_ACCURACY_THRESHOLD - else "Nothing matched" - ) - - # Update history - cls.update_history(user_input, best_match, best_accuracy) - - # Suggest flags if accuracy is low - if best_accuracy < MIN_ACCURACY_THRESHOLD: - suggested_flags = cls.__suggest_flags_based_on_history(user_input) - if suggested_flags: - log.warning( - f"No Flags matched so suggestions based on historical data: " - f"{', '.join(suggested_flags)}" - ) - - return best_match, best_accuracy - - @classmethod - def __colorify(cls, text: str, color: str) -> str: - """ - Colorize text with ANSI color codes. - - Args: - text (str): The text to colorize - color (str): The color code ('y' for yellow, 'r' for red, 'b' for blue) - - Returns: - str: The colorized text with ANSI escape codes - """ - colors = {"y": "\033[93m", "r": "\033[91m", "b": "\033[94m"} - RESET = "\033[0m" - return f"{colors.get(color, '')}{text}{RESET}" if color in colors else text - - @classmethod - def __available_arguments( - cls, - ) -> tuple[argparse.Namespace, argparse.ArgumentParser]: - """ - Defines and parses command-line arguments for the Logicytics application. - - This method creates an ArgumentParser with a comprehensive set of flags for customizing the application's behavior. It supports various execution modes, debugging options, system management flags, and post-execution actions. - - The method handles argument parsing, provides helpful descriptions for each flag, and includes color-coded hints for user guidance. It also supports suggesting valid flags if an unknown flag is provided. - - Returns: - tuple[argparse.Namespace, argparse.ArgumentParser]: A tuple containing: - - Parsed command-line arguments (Namespace) - - The configured argument parser object - """ - # Define the argument parser - parser = argparse.ArgumentParser( - description="Logicytics, The most powerful tool for system data analysis. " - "This tool provides a comprehensive suite of features for analyzing system data, " - "including various modes for different levels of detail and customization.", - allow_abbrev=False, - ) - - # Define Actions Flags - parser.add_argument( - "--default", - action="store_true", - help="Runs Logicytics with its default settings and scripts. " - f"{cls.__colorify('- Recommended for most users -', 'b')}", - ) - - parser.add_argument( - "--threaded", - action="store_true", - help="Runs Logicytics using threads, where it runs in parallel, default settings though" - f"{cls.__colorify('- Recommended for some users -', 'b')}", - ) - - parser.add_argument( - "--modded", - action="store_true", - help="Runs the normal Logicytics, as well as any File in the MODS directory, " - "Used for custom scripts as well as default ones.", - ) - - parser.add_argument( - "--depth", - action="store_true", - help="This flag will run all default script's in threading mode, " - "as well as any clunky and huge code, which produces a lot of data " - f"{cls.__colorify('- Will take a long time -', 'y')}", - ) - - parser.add_argument( - "--nopy", - action="store_true", - help="Run Logicytics using all non-python scripts, " - f"These may be {cls.__colorify('outdated', 'y')} " - "and not the best, use only if the device doesnt have python installed.", - ) - - # TODO v3.6.1 -> Out of beta - parser.add_argument( - "--vulnscan-ai", - action="store_true", - help="Run's Logicytics new Sensitive data Detection AI, its a new feature that will " - "detect any files that are out of the ordinary, and logs their path. Runs threaded." - f"{cls.__colorify('- Beta Mode -', 'y')} " - f"{cls.__colorify('- Will take a long time -', 'y')}", - ) - - parser.add_argument( - "--minimal", - action="store_true", - help="Run Logicytics in minimal mode. Just bare essential scraping using only quick scripts", - ) - - parser.add_argument( - "--performance-check", - action="store_true", - help="Run's Logicytics default while testing its performance and time, " - "this then shows a table with the file names and time to executed. ", - ) - - parser.add_argument( - "--usage", - action="store_true", - help="Run's script that shows and gives your local statistics, on the flags used by you", - ) - - # Define Side Flags - parser.add_argument( - "--debug", - action="store_true", - help="Runs the Debugger, Will check for any issues, " - "warning etc, useful for debugging and issue reporting " - f"{cls.__colorify('- Use to get a special log file to report the bug -', 'b')}.", - ) - - parser.add_argument( - "--update", - action="store_true", - help="Update Logicytics from GitHub, only if you have git properly installed " - "and the project was downloaded via git " - f"{cls.__colorify('- Use on your own device only -', 'y')}.", - ) - - parser.add_argument( - "--dev", - action="store_true", - help="Run Logicytics developer mod, this is only for people who want to " - "register their contributions properly. " - f"{cls.__colorify('- Use on your own device only -', 'y')}.", - ) - - # Define After-Execution Flags - parser.add_argument( - "--reboot", - action="store_true", - help="Execute Flag that will reboot the device afterward", - ) - - parser.add_argument( - "--shutdown", - action="store_true", - help="Execute Flag that will shutdown the device afterward", - ) - - # Parse the arguments - args, unknown = parser.parse_known_args() - valid_flags = [ - action.dest for action in parser._actions if action.dest != "help" - ] - if unknown: - cls.__suggest_flag(unknown[0], valid_flags) - exit(1) - return args, parser - - @staticmethod - def __exclusivity_logic(args: argparse.Namespace) -> bool: - """ - Validates the mutual exclusivity of command-line flags to prevent invalid flag combinations. - - This method checks for conflicting or mutually exclusive flags across three flag categories: - - Special flags (reboot, shutdown, webhook) - - Action flags (default, threaded, modded, minimal, nopy, depth, performance_check) - - Exclusive flags (vulnscan_ai) - - Parameters: - args (argparse.Namespace): Parsed command-line arguments to validate. - - Returns: - bool: True if any special flags are set, False otherwise. - - Raises: - SystemExit: If incompatible flag combinations are detected, with an error message describing the conflict. - """ - special_flags = { - args.reboot, - args.shutdown, - } - action_flags = { - args.default, - args.threaded, - args.modded, - args.minimal, - args.nopy, - args.depth, - args.performance_check, - args.usage, - } - exclusive_flags = { - args.vulnscan_ai, - } - - if any(special_flags) and not any(action_flags): - log.error( - "Invalid combination of flags_list: Special and Action flag exclusivity issue." - ) - exit(1) - - if any(exclusive_flags) and any(action_flags): - log.error( - "Invalid combination of flags_list: Exclusive and Action flag exclusivity issue." - ) - exit(1) - - if any(exclusive_flags) and any(special_flags): - log.error( - "Invalid combination of flags_list: Exclusive and Special flag exclusivity issue." - ) - exit(1) - - return any(special_flags) - - @staticmethod - def __used_flags_logic(args: argparse.Namespace) -> tuple[str, ...]: - """ - Determines the flags that are set to True in the provided command-line arguments. - - This method examines the arguments namespace and returns a tuple of flag names - that have been activated. It limits the returned flags to a maximum of two to - prevent excessive flag usage. - - Parameters: - args (argparse.Namespace): Parsed command-line arguments to be analyzed. - - Returns: - tuple[str, ...]: A tuple containing the names of flags set to True, - with a maximum of two flags. - - Notes: - - If no flags are set, returns an empty tuple. - - Stops collecting flags after finding two True flags to limit complexity. - """ - flags = {key: getattr(args, key) for key in vars(args)} - true_keys = [] - for key, value in flags.items(): - if value: - true_keys.append(key) - if len(true_keys) == 2: - break - return tuple(true_keys) - - @classmethod - def __suggest_flag(cls, user_input: str, valid_flags: list[str]): - """ - Suggests the closest valid flag based on the user's input and provides interactive flag matching. - - This method handles flag suggestion through two mechanisms: - 1. Using difflib to find close flag matches - 2. Prompting user for a description to find the most relevant flag - - Args: - user_input (str): The flag input by the user. - valid_flags (list[str]): The list of valid flags. - - Behavior: - - If a close flag match exists, suggests the closest match - - If no close match, prompts user for a description - - Uses the _Match.flag method to find the most accurate flag based on description - - Prints matching results, with optional detailed output in debug mode - - Side Effects: - - Prints suggestions and matched flags to console - - Prompts user for additional input if no direct match is found - """ - # Get the closest valid flag match based on the user's input - closest_matches = difflib.get_close_matches( - user_input, valid_flags, n=1, cutoff=0.6 - ) - if closest_matches: - log.warning( - f"Invalid flag '{user_input}', Did you mean '--{closest_matches[0].replace('_', '-')}'?" - ) - exit(1) - - # Prompt the user for a description if no close match is found - user_input_desc = input( - "We can't find a match, Please provide a description: " - ).lower() - - # Map the user-provided description to the closest valid flag - flags_list = [f"--{flag}" for flag in valid_flags] - descriptions_list = [f"Run Logicytics with {flag}" for flag in valid_flags] - flag_received, accuracy_received = cls.Match.flag( - user_input_desc, flags_list, descriptions_list - ) - if DEBUG_MODE: - log.info( - f"User input: {user_input_desc}\nMatched flag: {flag_received.replace('_', '-')}\nAccuracy: {accuracy_received:.2f}%\n" - ) - else: - log.info( - f"Matched flag: {flag_received.replace('_', '-')} (Accuracy: {accuracy_received:.2f}%)\n" - ) - - @staticmethod - def show_help_menu(return_output: bool = False) -> str | None: - """ - Display the help menu for the Logicytics application. - - This method retrieves the argument parser from the Flag class and either prints or returns the help text based on the input parameter. - - Args: - return_output (bool, optional): Controls the method's behavior. - - If True, returns the formatted help text as a string. - - If False (default), prints the help text directly to the console. - - Returns: - str or None: Help text as a string if return_output is True, otherwise None. - - Example: - # Print help menu to console - Flag.show_help_menu() - - # Get help menu as a string - help_text = Flag.show_help_menu(return_output=True) - print(help_text) - """ - parser = Flag.__available_arguments()[1] - if return_output: - return parser.format_help() - parser.print_help() - return None - - @classmethod - def data(cls) -> tuple[str, str | None]: - """ - Handles the parsing and validation of command-line flags. - - This method processes command-line arguments, validates their usage, and manages flag interactions. It ensures that: - - Only one primary action flag is used at a time - - Special flags are handled with specific logic - - No invalid flag combinations are permitted - - User history is optionally updated based on preferences - - Returns: - tuple[str, str | None]: A tuple containing: - - The primary matched flag - - An optional secondary flag (None if not applicable) - - Exits the program if no flags are used or invalid combinations are detected - - Raises: - SystemExit: Terminates the program with an error message for: - - Invalid flag combinations - - No flags specified - """ - args, parser = cls.__available_arguments() - special_flag_used = cls.__exclusivity_logic(args) - - used_flags = [flag for flag in vars(args) if getattr(args, flag)] - - if not special_flag_used and len(used_flags) > 1: - log.error("Invalid combination of flags: Maximum 1 action flag allowed.") - exit(1) - - if special_flag_used: - used_flags = cls.__used_flags_logic(args) - if len(used_flags) > 2: - log.error("Invalid combination of flags: Maximum 2 flag mixes allowed.") - exit(1) - - if not used_flags: - cls.show_help_menu() - exit(0) - - # Update history with the matched flag(s) - if not SAVE_PREFERENCES: - return None - - def update_data_history(matched_flag: str): - """ - Update the usage count for a specific flag in the user's interaction history. - - This method increments the usage count for a given flag in the historical data. If the flag - does not exist in the history, it initializes its count to 0 before incrementing. - - Parameters: - matched_flag (str): The flag whose usage count needs to be updated. - - Side Effects: - - Modifies the 'flags_usage' dictionary in the user's history file - - Saves the updated history data to a persistent storage - - Example: - update_data_history('--verbose') # Increments usage count for '--verbose' flag - """ - history_data = cls.Match.load_history() - # Ensure the flag exists in the flags_usage counter and increment it - if matched_flag.replace("--", "") not in history_data["flags_usage"]: - history_data["flags_usage"][matched_flag.replace("--", "")] = 0 - history_data["flags_usage"][matched_flag.replace("--", "")] += 1 - cls.Match.save_history(history_data) - - if len(used_flags) == 2: - for flag in used_flags: - update_data_history(flag) - return tuple(used_flags) - update_data_history(used_flags[0]) - return used_flags[0], None diff --git a/CODE/logicytics/Get.py b/CODE/logicytics/Get.py deleted file mode 100644 index 8954000e..00000000 --- a/CODE/logicytics/Get.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import os - - -class Get: - @staticmethod - def list_of_files( - directory: str, - only_extensions: list[str] = None, - append_file_list: list[str] = None, - exclude_files: list[str] = None, - exclude_extensions: list[str] = None, - exclude_dirs: list[str] = None, - ) -> list[str]: - """ - Retrieves a list of files in the specified directory based on given extensions and exclusion criteria. - - Parameters: - directory (str): Path of the directory to search for files. - only_extensions (list[str], optional): List of file extensions to filter. If None, retrieves all files. Defaults to None. - append_file_list (list[str], optional): Existing list to append found filenames to. Defaults to None. - exclude_files (list[str], optional): List of filenames to exclude from results. Defaults to None. - exclude_extensions (list[str], optional): List of extensions to exclude from results. Defaults to None. - exclude_dirs (list[str], optional): List of directory names to ignore. Defaults to None. - - Returns: - list[str]: A list of filenames matching the specified criteria. - - Exclusion rules: - - Ignores files starting with an underscore (_) - - Skips files specified in `exclude_files` - - Skips directories specified in `ignore_dirs` - """ - append_file_list = append_file_list or [] - exclude_files = set(exclude_files or []) - exclude_extensions = set(exclude_extensions or []) - exclude_dirs = set(exclude_dirs or []) # Set for faster lookup - - for root, dirs, filenames in os.walk(directory): - # Remove ignored directories from the dirs list to prevent os.walk from entering them - dirs[:] = [d for d in dirs if d not in exclude_dirs] - - for filename in filenames: - if filename.startswith("_") or filename in exclude_files: - continue # Skip excluded files - if any(filename.endswith(ext) for ext in exclude_extensions): - continue # Skip excluded files - - file_path = os.path.relpath(os.path.join(root, filename), directory) - - if only_extensions is None or any(filename.endswith(ext) for ext in only_extensions): - append_file_list.append(file_path) - - return append_file_list diff --git a/CODE/logicytics/Logger.py b/CODE/logicytics/Logger.py deleted file mode 100644 index d4078f33..00000000 --- a/CODE/logicytics/Logger.py +++ /dev/null @@ -1,454 +0,0 @@ -from __future__ import annotations - -import inspect -import logging -import os -import re -import time -from datetime import datetime -from typing import Type - -import colorlog - -from logicytics.Config import DEBUG -from logicytics.FileManagement import FileManagement - - -class Log: - """ - A logging class that supports colored output using the colorlog library. - """ - - _instance = None - - def __new__(cls, *args, **kwargs): - """ - Ensures that only one instance of the Log class is created (Singleton pattern). - - :param cls: The class being instantiated. - :param args: Positional arguments. - :param kwargs: Keyword arguments. - :return: The single instance of the Log class. - """ - if cls._instance is None: - cls._instance = super(Log, cls).__new__(cls) - cls._instance._initialized = False - return cls._instance - - def __init__(self, config: dict = None): - """ - Initializes the Log class with the given configuration. - - :param config: A dictionary containing configuration options. - """ - FileManagement.mkdir() # Ensure the necessary directories are created - if self._initialized and config is None: - return - self._initialized = True - if config: - self.reset() - # log_path_relative variable takes Logger.py full path, - # goes up twice then joins with ACCESS\\LOGS\\Logicytics.log - log_path_relative = os.path.join( - os.path.dirname( - os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - ), - "ACCESS\\LOGS\\Logicytics.log", - ) - config = config or { - "filename": log_path_relative, - "use_colorlog": True, - "log_level": "INFO", - "debug_color": "cyan", - "info_color": "green", - "warning_color": "yellow", - "error_color": "red", - "critical_color": "bold_red", - "exception_color": "red", - "colorlog_fmt_parameters": "%(log_color)s%(levelname)-8s%(reset)s %(blue)s%(message)s", - "truncate_message": True, - "delete_log": False, - } - self.EXCEPTION_LOG_LEVEL = 45 - self.INTERNAL_LOG_LEVEL = 15 - logging.addLevelName(self.EXCEPTION_LOG_LEVEL, "EXCEPTION") - logging.addLevelName(self.INTERNAL_LOG_LEVEL, "INTERNAL") - self.color = config.get("use_colorlog", True) - self.truncate = config.get("truncate_message", True) - - self.filename = config.get("filename", log_path_relative) - if self.color: - logger = colorlog.getLogger() - logger.setLevel(getattr(logging, config["log_level"].upper(), logging.INFO)) - handler = colorlog.StreamHandler() - log_colors = { - "INTERNAL": "cyan", - "DEBUG": config.get("debug_color", "cyan"), - "INFO": config.get("info_color", "green"), - "WARNING": config.get("warning_color", "yellow"), - "ERROR": config.get("error_color", "red"), - "CRITICAL": config.get("critical_color", "bold_red"), - "EXCEPTION": config.get("exception_color", "red"), - } - - formatter = colorlog.ColoredFormatter( - config.get( - "colorlog_fmt_parameters", - "%(log_color)s%(levelname)-8s%(reset)s %(blue)s%(message)s", - ), - log_colors=log_colors, - ) - - handler.setFormatter(formatter) - logger.addHandler(handler) - try: - getattr(logging, config["log_level"].upper()) - except AttributeError as AE: - self.__internal( - f"Log Level {config['log_level']} not found, setting default level to INFO -> {AE}" - ) - - if not os.path.exists(self.filename): - self.newline() - self._raw( - "| Timestamp | LOG Level |" - + " " * 71 - + "LOG Messages" - + " " * 71 - + "|" - ) - elif os.path.exists(self.filename) and config.get("delete_log", False): - with open(self.filename, "w") as f: - f.write( - "| Timestamp | LOG Level |" - + " " * 71 - + "LOG Messages" - + " " * 71 - + "|" - + "\n" - ) - self.newline() - - @staticmethod - def reset(): - """ - Resets the logger by removing all existing handlers. - """ - logger = logging.getLogger() - for handler in logger.handlers[:]: - logger.removeHandler(handler) - - @staticmethod - def __timestamp() -> str: - """ - Returns the current timestamp as a string. - - :return: Current timestamp in 'YYYY-MM-DD HH:MM:SS' format. - """ - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def __trunc_message(self, message: str) -> str: - """ - Pads or truncates the message to fit the log format. - - :param message: The log message to be padded or truncated. - :return: The padded or truncated message. - """ - if self.truncate is False: - return message + " " * (153 - len(message)) + "|" - return ( - message + " " * (153 - len(message)) - if len(message) < 153 - else message[:150] + "..." - ) + "|" - - def __internal(self, message): - """ - Log an internal message exclusively to the console. - - Internal messages are used for logging system states or debug information - that should not be written to log files. - These messages are only displayed in the console when color logging is enabled. - - Parameters: - message (str): The internal message to be logged. - If the message is "None" or None, no logging occurs. - - Notes: - - Requires color logging to be enabled - - Uses a custom internal log level - - Converts the message to a string before logging - """ - if self.color and message != "None" and message is not None: - colorlog.log(self.INTERNAL_LOG_LEVEL, str(message)) - - def debug(self, message): - """ - Logs a debug message. - - :param message: The debug message to be logged. - """ - if self.color and message != "None" and message is not None: - colorlog.debug(str(message)) - - def _raw(self, message): - """ - Log a raw message directly to the log file. - - This method writes a message directly to the log file without any additional formatting - or logging levels. - - WARNING: This method is for internal use only! Using it directly can mess up - your log file format and make it hard to read. Use info(), debug(), or - other public methods instead. - - Parameters: - message (str): The raw message to be written to the log file. - - Notes: - - Checks the calling context to warn about non-function calls - - Handles potential Unicode encoding errors - - Skips logging if message is None or "None" - - Writes message with a newline character - - Logs internal errors if file writing fails - - Raises: - Logs internal errors for Unicode or file writing issues without stopping execution - """ - frame = inspect.currentframe().f_back - if frame and frame.f_code.co_name == "": - self.__internal( - f"Raw message called from a non-function - This is not recommended" - ) - # Precompiled regex for ANSI escape codes - # Remove all ANSI escape sequences in one pass - message = re.compile(r"\033\[\d+(;\d+)*m").sub("", message) - - if message and message != "None": - try: - with open(self.filename, "a", encoding="utf-8") as f: - f.write(f"{str(message)}\n") - except (UnicodeDecodeError, UnicodeEncodeError) as UDE: - self.__internal(f"UnicodeDecodeError: {UDE} - Message: {str(message)}") - except Exception as E: - self.__internal(f"Error: {E} - Message: {str(message)}") - - def newline(self): - """ - Write a newline separator to the log file, creating a visual divider between log entries. - - This method writes a formatted horizontal line to the log file using ASCII characters, - which helps visually separate different sections or log entries. - The line consists of vertical bars and dashes creating a structured tabular-like separator. - - Side Effects: - Appends a newline separator to the log file specified by `self.filename`. - """ - with open(self.filename, "a") as f: - f.write("|" + "-" * 19 + "|" + "-" * 13 + "|" + "-" * 154 + "|" + "\n") - - def info(self, message): - """ - Logs an info message. - - :param message: The info message to be logged. - """ - if self.color and message != "None" and message is not None: - colorlog.info(str(message)) - self._raw( - f"[{self.__timestamp()}] > INFO: | {self.__trunc_message(str(message))}" - ) - - def warning(self, message): - """ - Logs a warning message. - - :param message: The warning message to be logged. - """ - if self.color and message != "None" and message is not None: - colorlog.warning(str(message)) - self._raw( - f"[{self.__timestamp()}] > WARNING: | {self.__trunc_message(str(message))}" - ) - - def error(self, message): - """ - Logs an error message. - - :param message: The error message to be logged. - """ - if self.color and message != "None" and message is not None: - colorlog.error(str(message)) - self._raw( - f"[{self.__timestamp()}] > ERROR: | {self.__trunc_message(str(message))}" - ) - - def critical(self, message): - """ - Logs a critical message. - - :param message: The critical message to be logged. - """ - if self.color and message != "None" and message is not None: - colorlog.critical(str(message)) - self._raw( - f"[{self.__timestamp()}] > CRITICAL: | {self.__trunc_message(str(message))}" - ) - - def string(self, message, type: str): - """ - Logs a message with a specified log type, supporting multiple type aliases. - - This method allows logging messages with flexible type specifications, - mapping aliases to standard log types and handling potential errors in type selection. - It supports logging with color if enabled. - - Parameters: - message (str): The message to be logged. Skipped if "None" or None. - type (str): The log type, which can be one of: - - Standard types: 'debug', 'info', 'warning', 'error', 'critical' - - Aliases: 'err' (error), 'warn' (warning), 'crit' (critical), 'except' (exception) - - Behavior: - - Converts type to lowercase and maps aliases to standard log types - - Logs message using the corresponding log method - - Falls back to debug logging if an invalid type is provided - - Only logs if color is enabled and message is not "None" - - Raises: - AttributeError: If no matching log method is found (internally handled) - """ - if self.color and message != "None" and message is not None: - type_map = { - "err": "error", - "warn": "warning", - "crit": "critical", - "except": "exception", - } - type = type_map.get(type.lower(), type) - try: - getattr(self, type.lower())(str(message)) - except AttributeError as AE: - self.__internal( - f"A wrong Log Type was called: {type} not found. -> {AE}" - ) - getattr(self, "Debug".lower())(str(message)) - - def exception(self, message, exception_type: Type = Exception): - """ - Log an exception message and raise the specified exception. - - Warning: Not recommended for production use. Prefer Log().error() for logging exceptions. - - Args: - message (str): The exception message to be logged. - exception_type (Type, optional): The type of exception to raise. Defaults to Exception. - - Raises: - The specified exception type with the provided message. - - Note: - - Only logs the exception if color logging is enabled and message is not None - - Logs the exception with a timestamp and truncated message - - Includes both the original message and the exception type in the log - """ - if self.color and message != "None" and message is not None: - self._raw( - f"[{self.__timestamp()}] > EXCEPTION:| {self.__trunc_message(f'{message} -> Exception provoked: {str(exception_type)}')}" - ) - raise exception_type(message) - - def execution(self, message_log: list[tuple[str, str]]): - """ - Parse and log multiple messages with their corresponding log types. - - This method processes a list of messages, where each message is associated with a specific log type. It is designed for scenarios where multiple log entries need to be processed simultaneously, such as logging script execution results. - - Parameters: - message_log (list[tuple[str, str]]): A list of message entries. - Each entry is a list containing two elements: - - First element: The log message (str) - - Second element: The log type (str) - - Behavior: - - Iterates through the provided message log - - Logs each message using the specified log type via `self.string()` - - Logs an internal warning if a message list does not contain exactly two elements - - Example: - log = Log() - log.parse_execution([ - ['Operation started', 'info'], - ['Processing data', 'debug'], - ['Completed successfully', 'info'] - ]) - """ - if message_log: - for message_list in message_log: - if len(message_list) == 2: - self.string(message_list[0], message_list[1]) - else: - self.__internal( - f"Message List is not in the correct format: {message_list}" - ) - - def function(self, func: callable): - """ - A decorator that logs the execution details of a function, tracking its performance and providing runtime insights. - - Parameters: - func (callable): The function to be decorated and monitored. - - Returns: - callable: A wrapper function that logs execution metrics. - - Raises: - TypeError: If the provided function is not callable. - - Example: - @log.function - def example_function(): - # Function implementation - pass - """ - if not callable(func): - self.exception(f"Function {func.__name__} is not callable.", TypeError) - - def wrapper(*args, **kwargs): - """ - Wrapper function that logs the execution of the decorated function. - - Tracks and logs the start, execution, and completion of a function with performance timing. - - Parameters: - *args (tuple): Positional arguments passed to the decorated function. - **kwargs (dict): Keyword arguments passed to the decorated function. - - Returns: - Any: The original result of the decorated function. - - Raises: - TypeError: If the decorated function is not callable. - - Notes: - - Logs debug messages before and after function execution - - Measures and logs the total execution time with microsecond precision - - Preserves the original function's return value - """ - start_time = time.perf_counter() - func_args = ", ".join( - [str(arg) for arg in args] + [f"{k}={v}" for k, v in kwargs.items()] - ) - self.debug(f"Running the function {func.__name__}({func_args}).") - result = func(*args, **kwargs) - end_time = time.perf_counter() - elapsed_time = end_time - start_time - self.debug( - f"{func.__name__}({func_args}) executed in {elapsed_time} -> returned {type(result).__name__}" - ) - return result - - return wrapper - - -log = Log({"log_level": DEBUG}) diff --git a/CODE/logicytics/__init__.py b/CODE/logicytics/__init__.py deleted file mode 100644 index 2e540e0c..00000000 --- a/CODE/logicytics/__init__.py +++ /dev/null @@ -1,126 +0,0 @@ -import functools -import traceback - -from logicytics.Checks import Check -from logicytics.Config import DEBUG, VERSION, CURRENT_FILES, DELETE_LOGS, config -from logicytics.Execute import Execute -from logicytics.FileManagement import FileManagement -from logicytics.Flag import Flag -from logicytics.Get import Get -from logicytics.Logger import log, Log - -# Check if the script is being run directly, if not, set up the library -if __name__ == "__main__": - exit("This is a library, Please import rather than directly run.") - -execute = Execute() # Initialize the Execute class for executing commands -get = Get() # Initialize the Get class for retrieving data -check = Check() # Initialize the Check class for performing checks -flag = Flag() # Initialize the Flag class for managing cli flags -file_management = ( - FileManagement() -) # Initialize the FileManagement class for file operations -__show_trace = ( - DEBUG == "DEBUG" -) # Determine if stack traces should be shown based on the debug level - - -# Exception for handling object loading errors -class ObjectLoadError(Exception): - """Raised when an Object fails to load.""" - - def __init__(self, message="Failed to load object", object_name=None): - """ - Initialize the exception with a custom message and object details. - - Args: - message (str): The error message - object_name (str, optional): Name of the object that failed to load - """ - self.object_name = object_name - if object_name: - message = f"{message} (Object: {object_name})" - super().__init__(message) - - -# Decorator for marking functions as deprecated [custom] -def deprecated( - removal_version: str, reason: str, show_trace: bool = __show_trace -) -> callable: - """ - Decorator function that marks a function as deprecated - and provides a warning when the function is called. - - Args: - removal_version (str): The version when the function will be removed. - reason (str): The reason for deprecation. - show_trace (bool): Whether to show the stack trace when the function is called. Default is based on DEBUG set by user. - - Returns: - callable: A decorator that marks a function as deprecated. - - Notes: - - Uses a nested decorator function to preserve the original function's metadata - - Prints a colorized deprecation warning - """ - - def decorator(func: callable) -> callable: - """ - Decorator function that marks a function as deprecated and provides a warning when the function is called. - - Args: - func (callable): The function to be decorated with a deprecation warning. - - Returns: - callable: A wrapper function that preserves the original function's metadata and prints a deprecation warning. - - Notes: - - Uses functools.wraps to preserve the original function's metadata - - Prints a colorized deprecation warning to stderr - - Allows the original function to continue executing - """ - - @functools.wraps(func) - def wrapper(*args, **kwargs) -> callable: - """ - Wraps a deprecated function to print a warning message before execution. - - Args: - *args: Positional arguments passed to the original function. - **kwargs: Keyword arguments passed to the original function. - - Returns: - The return value of the original function after printing a deprecation warning. - - Warns: - Prints a colored deprecation warning to stderr with details about: - - Function name being deprecated - - Reason for deprecation - - Version when the function will be removed - """ - message = f"\033[91mDeprecationWarning: A call to the deprecated function {func.__name__}() has been called, {reason}. Function will be removed at version {removal_version}\n" - if show_trace: - stack = "".join(traceback.format_stack()[:-1]) - message += f"Called from:\n{stack}\033[0m" - else: - message += "\033[0m" - print(message) - return func(*args, **kwargs) - - return wrapper - - return decorator - - -__all__ = [ - "execute", - "get", - "check", - "flag", - "file_management", - "deprecated", - "ObjectLoadError", - "log", - "Log", - "config", -] diff --git a/CODE/media_backup.py b/CODE/media_backup.py deleted file mode 100644 index e2480e0f..00000000 --- a/CODE/media_backup.py +++ /dev/null @@ -1,138 +0,0 @@ -import getpass -import os -import shutil -from datetime import datetime - -from logicytics import log - - -class Media: - """ - A class to handle media backup operations. - """ - - @staticmethod - def __get_default_paths() -> list: - """ - Returns the default paths for photos and videos based on the Windows username. - - This method retrieves the current Windows user's default media directories for photos and videos - by using the current username and standard Windows file system paths. - - Returns: - list: A list containing two paths: - - First element: Default photo directory path - - Second element: Default video directory path - - Notes: - - Uses `getpass.getuser()` to dynamically retrieve the current Windows username - - Expands the user path using `os.path.expanduser()` to handle potential path variations - - Assumes standard Windows user directory structure - """ - username = getpass.getuser() - default_photo_path = os.path.expanduser(f"C:\\Users\\{username}\\Pictures") - default_video_path = os.path.expanduser(f"C:\\Users\\{username}\\Videos") - return [default_photo_path, default_video_path] - - @staticmethod - def __ensure_backup_directory_exists(backup_directory: str): - """ - Ensures the backup directory exists, creating it if necessary. - - Args: - backup_directory (str): The full path to the directory where media files will be backed up. - - Raises: - OSError: If the directory cannot be created due to permission issues or other system constraints. - """ - if not os.path.exists(backup_directory): - os.makedirs(backup_directory) - - @staticmethod - def __collect_media_files(source_dirs: list) -> list: - """ - Collects media files from specified source directories. - - Recursively searches through the provided source directories to find image and video files with extensions .jpg, .jpeg, .png, and .mp4. - - Args: - source_dirs (list): List of directory paths to search for media files. - - Returns: - list: Absolute file paths of all discovered media files, including those in subdirectories. - - Raises: - OSError: If any of the source directories are inaccessible or cannot be traversed. - """ - media_files = [] - for source_dir in source_dirs: - for root, _, files in os.walk(source_dir): - for file in files: - if file.endswith((".jpg", ".jpeg", ".png", ".mp4")): - media_files.append(os.path.join(root, file)) - return media_files - - @staticmethod - def __backup_files(media_files: list, backup_directory: str): - """ - Copies media files to a backup directory with timestamped filenames. - - Parameters: - media_files (list): A list of file paths for media files to be backed up. - backup_directory (str): Destination directory path for storing backup files. - - Behavior: - - Iterates through each media file in the input list - - Generates a new filename with current timestamp - - Attempts to copy each file to the backup directory - - Logs successful copy operations - - Logs any errors encountered during file copying - - Exceptions: - Handles and logs any exceptions that occur during file copy process - Does not interrupt the entire backup process if a single file copy fails - """ - for src_file in media_files: - dst_file = os.path.join( - backup_directory, - datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - + "_" - + os.path.basename(src_file), - ) - try: - shutil.copy2(str(src_file), str(dst_file)) - log.info(f"Copied {os.path.basename(src_file)} to {dst_file}") - except Exception as e: - log.error(f"Failed to copy {src_file}: {str(e)}") - - @classmethod - @log.function - def backup(cls): - """ - Orchestrates the complete media backup process by performing sequential backup operations. - - This class method coordinates the backup workflow: - 1. Retrieves default media source directories - 2. Sets a standard backup directory - 3. Ensures the backup directory exists - 4. Collects media files from source directories - 5. Copies media files to the backup directory - 6. Logs the completion of the backup process - - Returns: - None: Performs backup operations without returning a value - - Raises: - OSError: If directory creation or file operations fail - PermissionError: If insufficient permissions for file/directory operations - """ - source_dirs = cls.__get_default_paths() - backup_directory = "MediaBackup" - cls.__ensure_backup_directory_exists(backup_directory) - media_files = cls.__collect_media_files(source_dirs) - cls.__backup_files(media_files, backup_directory) - log.info("Media backup script completed.") - - -if __name__ == "__main__": - Media.backup() diff --git a/CODE/netadapter.ps1 b/CODE/netadapter.ps1 deleted file mode 100644 index 9453acea..00000000 --- a/CODE/netadapter.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -# Get all network details -Write-Output "INFO: Getting NetAdapter Info" -Get-NetAdapter | Select-Object Name, Status, MacAddress, ifIndex, InterfaceAlias, InterfaceDescription | Out-File -FilePath .\Network.txt diff --git a/CODE/network_psutil.py b/CODE/network_psutil.py deleted file mode 100644 index 2fee2afe..00000000 --- a/CODE/network_psutil.py +++ /dev/null @@ -1,195 +0,0 @@ -import asyncio -import os -import socket - -import psutil - -from logicytics import log, execute, config - - -class NetworkInfo: - """ - A class to gather and save various network-related information. - """ - - def __init__(self): - self.SAMPLE_COUNT = config.getint("NetWorkPsutil Settings", "sample_count") - self.INTERVAL = config.getfloat("NetWorkPsutil Settings", "interval") - - @log.function - async def get(self): - """ - Gathers and saves various network-related information by calling multiple internal methods. - """ - try: - self.__fetch_network_io_stats() - self.__fetch_network_connections() - self.__fetch_network_interface_addresses() - self.__fetch_network_interface_stats() - self.__execute_external_network_command() - self.__fetch_network_connections_with_process_info() - await self.__measure_network_bandwidth_usage(sample_count=self.SAMPLE_COUNT, interval=self.INTERVAL) - self.__fetch_hostname_and_ip() - except Exception as e: - log.error(f"Error getting network info: {e}, Type: {type(e).__name__}") - - @staticmethod - def __save_data(filename: str, data: str, father_dir_name: str = "network_data"): - """ - Saves the given data to a file. - - :param filename: The name of the file to save the data in. - :param data: The data to be saved. - :param father_dir_name: The directory to save the file in. Defaults to "network_data". - """ - os.makedirs(father_dir_name, exist_ok=True) - try: - with open(os.path.join(father_dir_name, filename), "w") as f: - f.write(data) - except IOError as e: - log.error(f"Failed to save {filename}: {e}") - - def __fetch_network_io_stats(self): - """ - Fetches and saves network I/O statistics for each network interface. - """ - log.debug("Fetching network interface stats...") - net_io = psutil.net_io_counters(pernic=True) - net_io_data = "" - for iface, stats in net_io.items(): - net_io_data += f"Interface: {iface}\n" - net_io_data += f"Bytes Sent: {stats.bytes_sent}, Bytes Received: {stats.bytes_recv}\n" - net_io_data += f"Packets Sent: {stats.packets_sent}, Packets Received: {stats.packets_recv}\n" - net_io_data += f"Errors In: {stats.errin}, Errors Out: {stats.errout}\n" - net_io_data += f"Dropped In: {stats.dropin}, Dropped Out: {stats.dropout}\n\n" - self.__save_data("network_io.txt", net_io_data) - log.info("Network IO stats saved.") - - def __fetch_network_connections(self): - """ - Fetches and saves information about network connections. - """ - log.debug("Fetching network connections...") - connections = psutil.net_connections(kind='all') - connections_data = "" - for conn in connections: - connections_data += f"Type: {conn.type}, Local: {conn.laddr}, Remote: {conn.raddr}, Status: {conn.status}\n" - self.__save_data("network_connections.txt", connections_data) - log.info("Network connections saved.") - - def __fetch_network_interface_addresses(self): - """ - Fetches and saves network interface addresses. - """ - log.debug("Fetching network interface addresses...") - interfaces = psutil.net_if_addrs() - interfaces_data = "" - for iface, addrs in interfaces.items(): - for addr in addrs: - interfaces_data += f"Interface: {iface}, Address: {addr.address}, Netmask: {addr.netmask}, Broadcast: {addr.broadcast}\n" - self.__save_data("network_interfaces.txt", interfaces_data) - log.info("Network interface addresses saved.") - - def __fetch_network_interface_stats(self): - """ - Fetches and saves network interface statistics. - """ - log.debug("Fetching network interface stats...") - stats = psutil.net_if_stats() - stats_data = "" - for iface, stat in stats.items(): - stats_data += f"Interface: {iface}, Speed: {stat.speed}Mbps, Duplex: {stat.duplex}, Up: {stat.isup}\n" - self.__save_data("network_stats.txt", stats_data) - log.info("Network interface stats saved.") - - def __execute_external_network_command(self): - """ - Executes an external network command and saves the output. - """ - log.debug("Executing external network command...") - result = execute.command("ipconfig") - self.__save_data("network_command_output.txt", result) - log.info("Network command output saved.") - - def __fetch_network_connections_with_process_info(self): - """ - Fetches and saves network connections along with associated process information. - """ - log.debug("Fetching network connections with process info...") - connections_data = "" - for conn in psutil.net_connections(kind='all'): - pid = conn.pid if conn.pid else "N/A" - proc_name = "Unknown" - if pid != "N/A": - try: - proc_name = psutil.Process(pid).name() - except psutil.NoSuchProcess: - proc_name = "Process Exited" - connections_data += f"Type: {conn.type}, Local: {conn.laddr}, Remote: {conn.raddr}, Status: {conn.status}, Process: {proc_name} (PID: {pid})\n" - self.__save_data("network_connections_with_processes.txt", connections_data) - log.info("Network connections with process info saved.") - - async def __measure_network_bandwidth_usage(self, sample_count: int = 5, interval: float = 1.0): - """ - Measures and saves the average network bandwidth usage. - - Args: - sample_count: Number of samples to take (default: 5) - interval: Time between samples in seconds (default: 1.0) - """ - if sample_count < 1 or interval <= 0: - log.critical( - "Invalid values passed down from configuration for `NetworkInfo.__measure_network_bandwidth_usage()`") - log.debug("Measuring network bandwidth usage...") - samples = [] - for _ in range(sample_count): - net1 = psutil.net_io_counters() - await asyncio.sleep(interval) - net2 = psutil.net_io_counters() - samples.append({ - 'up': (net2.bytes_sent - net1.bytes_sent) / 1024, - 'down': (net2.bytes_recv - net1.bytes_recv) / 1024 - }) - if samples: - avg_up = sum(s['up'] for s in samples) / len(samples) - avg_down = sum(s['down'] for s in samples) / len(samples) - max_up = max(s['up'] for s in samples) - max_down = max(s['down'] for s in samples) - else: - avg_up = avg_down = max_up = max_down = 0 - bandwidth_data = f"Average Upload Speed: {avg_up:.2f} KB/s\n" - bandwidth_data += f"Average Download Speed: {avg_down:.2f} KB/s\n" - bandwidth_data += f"Peak Upload Speed: {max_up:.2f} KB/s\n" - bandwidth_data += f"Peak Download Speed: {max_down:.2f} KB/s\n" - self.__save_data("network_bandwidth_usage.txt", bandwidth_data) - log.info("Network bandwidth usage saved.") - - def __fetch_hostname_and_ip(self): - """ - Fetches and saves the hostname and IP addresses of the machine. - """ - try: - hostname = socket.gethostname() - ip_addresses = [] - for res in socket.getaddrinfo(hostname, None): - ip = res[4][0] - if ip not in ip_addresses: - ip_addresses.append(ip) - ip_config_data = f"Hostname: {hostname}\n" - ip_config_data += "IP Addresses:\n" - for ip in ip_addresses: - ip_config_data += f" - {ip}\n" - except socket.gaierror as e: - log.error(f"Failed to resolve hostname: {e}") - ip_config_data = f"Hostname: {hostname}\nFailed to resolve IP addresses\n" - self.__save_data("hostname_ip.txt", ip_config_data) - log.info("Hostname and IP address saved.") - - -if __name__ == "__main__": - try: - asyncio.run(NetworkInfo().get()) # Use asyncio.run to run the async get method - except asyncio.CancelledError: - log.warning("Operation cancelled by user.") - except Exception as err: # Catch all exceptions - log.error(f"An error occurred: {err}") diff --git a/CODE/packet_sniffer.py b/CODE/packet_sniffer.py deleted file mode 100644 index 95d85627..00000000 --- a/CODE/packet_sniffer.py +++ /dev/null @@ -1,149 +0,0 @@ -from __future__ import annotations - -import warnings -from time import time - -import matplotlib.pyplot as plt -import networkx as nx -import pandas as pd -from cryptography.utils import CryptographyDeprecationWarning - -# TripleDES deprecation warning -warnings.filterwarnings("ignore", category=CryptographyDeprecationWarning) -warnings.filterwarnings("ignore", category=DeprecationWarning) - -from scapy.all import sniff, conf -from scapy.layers.inet import IP, TCP, UDP, ICMP - -from logicytics import log, config - - -class PacketSniffer: - def __init__(self): - conf.verb = 0 - self.packet_data = [] - self.G = nx.Graph() - - @staticmethod - def _get_protocol(packet: IP) -> str: - if packet.haslayer(TCP): - return "TCP" - elif packet.haslayer(UDP): - return "UDP" - elif packet.haslayer(ICMP): - return "ICMP" - return "Other" - - @staticmethod - def _get_port(packet: IP, port_type: str) -> int | None: - if port_type == "sport": - return getattr(packet[TCP], "sport", None) if packet.haslayer(TCP) else getattr(packet[UDP], "sport", None) - elif port_type == "dport": - return getattr(packet[TCP], "dport", None) if packet.haslayer(TCP) else getattr(packet[UDP], "dport", None) - return None - - def _log_packet(self, packet: IP): - if not packet.haslayer(IP): - return - - try: - protocol = self._get_protocol(packet) - src_ip = packet[IP].src - dst_ip = packet[IP].dst - - src_port = dst_port = None - if protocol in ("TCP", "UDP"): - src_port = self._get_port(packet, "sport") - dst_port = self._get_port(packet, "dport") - - info = { - "src_ip": src_ip, - "dst_ip": dst_ip, - "protocol": protocol, - "src_port": src_port, - "dst_port": dst_port - } - - self.packet_data.append(info) - self.G.add_edge(src_ip, dst_ip, protocol=protocol) - log.debug(f"{protocol} {src_ip}:{src_port} -> {dst_ip}:{dst_port}") - except Exception as err: - log.error(f"Error logging packet: {err}") - - def _save_to_csv(self, path: str): - if not self.packet_data: - log.warning("No packets to save.") - return - pd.DataFrame(self.packet_data).to_csv(path, index=False) - log.info(f"Saved packet data to {path}") - - def _visualize_graph(self, output: str = "graph.png"): - if self.G.number_of_edges() == 0: - log.warning("No edges to plot in graph.") - return - - pos = nx.spring_layout(self.G) - plt.figure(figsize=(12, 8)) - nx.draw(self.G, pos, with_labels=True, node_color="skyblue", node_size=3000, font_size=10, font_weight="bold") - labels = nx.get_edge_attributes(self.G, 'protocol') - nx.draw_networkx_edge_labels(self.G, pos, edge_labels=labels) - plt.title("Network Graph") - plt.savefig(output) - plt.close() - log.info(f"Graph saved to {output}") - - @staticmethod - def _correct_interface(iface: str) -> str: - corrections = {"WiFi": "Wi-Fi", "Wi-Fi": "WiFi"} - return corrections.get(iface, iface) - - def sniff_packets(self, iface: str, count: int, timeout: int, retry_max: int): - iface = self._correct_interface(iface) - retry_start = time() - - while time() - retry_start < retry_max: - try: - log.info(f"Sniffing on {iface}... (count={count}, timeout={timeout})") - sniff( - iface=iface, - prn=self._log_packet, - count=count, - timeout=timeout - ) - log.info("Sniff complete.") - break - except Exception as err: - log.warning(f"Sniff failed on {iface}: {err}") - iface = self._correct_interface(iface) - else: - log.error("Max retry time exceeded.") - - self._save_to_csv("packets.csv") - self._visualize_graph() - - def run(self): - iface = config.get("PacketSniffer Settings", "interface", fallback="WiFi") - count = config.getint("PacketSniffer Settings", "packet_count", fallback=5000) - timeout = config.getint("PacketSniffer Settings", "timeout", fallback=10) - retry_max = config.getint("PacketSniffer Settings", "max_retry_time", fallback=30) - - if count <= 0 or timeout < 5 or retry_max < timeout: - log.critical("Invalid configuration values.") - return - - self.sniff_packets(iface, count, timeout, retry_max) - - def cleanup(self): - self.G.clear() - plt.close("all") - log.info("Cleanup complete.") - - -if __name__ == "__main__": - sniffer = PacketSniffer() - try: - sniffer.run() - except Exception as e: - log.error(f"Fatal error: {e}") - finally: - sniffer.cleanup() diff --git a/CODE/property_scraper.ps1 b/CODE/property_scraper.ps1 deleted file mode 100644 index 93ef3524..00000000 --- a/CODE/property_scraper.ps1 +++ /dev/null @@ -1,34 +0,0 @@ -# Collect system information -$buildNumber = [System.Environment]::OSVersion.Version.Build -$physicalMemory = [System.Diagnostics.Process]::PhysicalMemorySize64 / 1MB -$virtualMemory = [System.Diagnostics.Process]::WorkingSet64 / 1MB -$userName = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name -$userSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent().UserValue) -$userLanguageId = [System.Globalization.CultureInfo]::CurrentCulture.LCID -$computerName = [System.Net.Dns]::GetHostName() -$systemLanguageId = [System.Globalization.CultureInfo]::CurrentUICulture.LCID -$time = Get-Date -Format HH:mm:ss -$date = Get-Date -Format dd/MM/yyyy -$rootDrive = $env:SystemDrive - -# Prepare the data to be written to the File -$data = @" -Property(C): Windows Build = $buildNumber -Property(C): Physical Memory = $( $physicalMemory -as [int] ) -Property(C): Virtual Memory = $( $virtualMemory -as [int] ) -Property(C): Log on User = $userName -Property(C): User SID = $userSid -Property(C): User Language ID = $userLanguageId -Property(C): Computer Name = $computerName -Property(C): System Language ID = $systemLanguageId -Property(C): Time = $time -Property(C): Date = $date -Property(C): Username = $userName -Property(C): Root Drive = $rootDrive -"@ - -# Write the data to a text File -$data | Out-File -FilePath ".\Extra_Data.txt" - -# Optionally, display a message indicating success -Write-Host "INFO: Data successfully written to Extra_Data.txt" diff --git a/CODE/registry.py b/CODE/registry.py deleted file mode 100644 index e1fa3522..00000000 --- a/CODE/registry.py +++ /dev/null @@ -1,29 +0,0 @@ -import os -import subprocess - -from logicytics import log - - -@log.function -def backup_registry(): - """ - Backs up the Windows registry to a file named 'RegistryBackup.reg' in the current working directory. - - This function uses the reg export command to export the entire - registry (HKEY_LOCAL_MACHINE) and logs the result. - """ - export_path = os.path.join(os.getcwd(), "RegistryBackup.reg") - reg_path = r"C:\Windows\System32\reg.exe" - cmd = [reg_path, "export", "HKLM", export_path] - - try: - result = subprocess.run(cmd, check=True, capture_output=True, text=True) - log.info(f"Registry backed up successfully to {export_path}. Output: {result.stdout}") - except subprocess.CalledProcessError as e: - log.error(f"Failed to back up the registry: {e}.") - except Exception as e: - log.error(f"Failed to back up the registry: {e}") - - -if __name__ == "__main__": - backup_registry() diff --git a/CODE/sensitive_data_miner.py b/CODE/sensitive_data_miner.py deleted file mode 100644 index 89b92324..00000000 --- a/CODE/sensitive_data_miner.py +++ /dev/null @@ -1,179 +0,0 @@ -import os -import shutil -from concurrent.futures import ThreadPoolExecutor - -from pathlib import Path - -from logicytics import log - -# List of allowed extensions -allowed_extensions = [ - ".png", ".txt", ".md", ".json", ".yaml", ".secret", ".jpg", ".jpeg", - ".password", ".text", ".docx", ".doc", ".xls", ".xlsx", ".csv", - ".xml", ".config", ".log", ".pdf", ".zip", ".rar", ".7z", ".tar", - ".gz", ".tgz", ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", - ".sql", ".db", ".dbf", ".sqlite", ".sqlite3", ".bak", ".dbx", - ".mdb", ".accdb", ".pst", ".ost", ".msg", ".eml", ".vsd", - ".vsdx", ".vsdm", ".vss", ".vssx", ".vssm", ".vst", ".vstx", - ".vstm", ".vdx", ".vsx", ".vtx", ".vdw", ".vsw", ".vst", - ".mpp", ".mppx", ".mpt", ".mpd", ".mpx", ".mpd", ".mdf", -] - - -class Mine: - @staticmethod - def __search_files_by_keyword(root: Path, keyword: str) -> list: - """ - Searches for files containing a specified keyword in their names within a given directory. - - Parameters: - root (Path): The root directory to search in for files. - keyword (str): The keyword to search for in file names (case-insensitive). - - Returns: - list: A list of file paths matching the search criteria, which: - - Contain the keyword in their filename (case-insensitive) - - Are files (not directories) - - Have file extensions in the allowed_extensions list - - Raises: - WindowsError: If permission is denied when accessing the directory (logged as a warning in debug mode) - - Notes: - - Skips files with unsupported extensions, logging debug information - - Uses case-insensitive keyword matching - """ - matching_files = [] - path_list = [] - try: - path_list = os.listdir(root) - except (WindowsError, PermissionError) as e: - log.warning(f"Permission Denied: {e}") - except Exception as e: - log.error(f"Failed to access directory: {e}") - - for filename in path_list: - file_path = root / filename - if ( - keyword.lower() in filename.lower() - and file_path.is_file() - and file_path.suffix in allowed_extensions - ): - matching_files.append(file_path) - else: - log.debug(f"Skipped {file_path}, Unsupported due to {file_path.suffix} extension") - return matching_files - - @staticmethod - def __copy_file(src_file_path: Path, dst_file_path: Path): - """ - Copy a file from the source path to the destination path. - - Parameters: - src_file_path (Path): The full path of the source file to be copied. - dst_file_path (Path): The full path where the file will be copied. - - Raises: - FileExistsError: If a file already exists at the destination path. - Exception: For any other unexpected errors during file copying. - - Notes: - - Uses shutil.copy() for file copying - - Logs debug message on successful copy - - Logs warning if file already exists - - Logs error for any other copying failures - """ - try: - # Check file size and permissions - if src_file_path.stat().st_size > 10_000_000: # 10MB limit - log.warning("File exceeds size limit") - return - shutil.copy(src_file_path, dst_file_path) - log.debug(f"Copied {src_file_path} to {dst_file_path}") - except FileExistsError as e: - log.warning(f"File already exists in destination: {e}") - except Exception as e: - log.error(f"Failed to copy file: {e}") - - @classmethod - def __search_and_copy_files(cls, keyword: str): - """ - Searches for files containing the specified keyword in their names and copies them to a destination directory. - - This method performs a comprehensive file search across the C: drive, identifying files that match a given keyword and concurrently copying them to a dedicated "Password_Files" directory. - - Parameters: - keyword (str): The keyword to search for in file names. Used to filter and identify potentially sensitive files. - - Side Effects: - - Creates a "Password_Files" directory if it does not exist - - Logs informational messages about the search and copy process - - Utilizes multithreading to efficiently search and copy files - - Notes: - - Searches recursively through all directories starting from C:\ - - Uses ThreadPoolExecutor for concurrent file searching and copying - - Handles potential permission and file access errors during search - """ - log.info(f"Searching/Copying file's with keyword: {keyword}") - drives_root = Path("C:\\") - destination = Path("Password_Files") - if not destination.exists(): - destination.mkdir() - - with ThreadPoolExecutor() as executor: - for root, dirs, _ in os.walk(drives_root): - future_to_file = { - executor.submit(cls.__search_files_by_keyword, Path(root) / sub_dir, keyword): sub_dir - for sub_dir in dirs - } - for future in future_to_file: - for file_path in future.result(): - dst_file_path = destination / file_path.name - executor.submit(cls.__copy_file, file_path, dst_file_path) - - @classmethod - @log.function - def passwords(cls): - """ - Searches for and copies files containing sensitive data keywords to a dedicated directory. - - This method performs a comprehensive search for files with predefined sensitive keywords in their names, - copying matching files to a "Password_Files" directory. It handles directory cleanup and uses predefined - keywords related to sensitive information. - - Side Effects: - - Creates or recreates the "Password_Files" directory - - Copies files matching sensitive keywords to the destination directory - - Logs the completion of the sensitive data mining process - - Keywords Searched: - - "password" - - "secret" - - "code" - - "login" - - "api" - - "key" - - Logging: - - Logs an informational message upon completion of the search and copy process - """ - keywords = ["password", "secret", "code", "login", "api", "key", - "token", "auth", "credentials", "private", "cert", "ssh", "pgp", "wallet"] - - # Ensure the destination directory is clean - destination = Path("Password_Files") - if destination.exists(): - shutil.rmtree(destination) - destination.mkdir() - - for word in keywords: - cls.__search_and_copy_files(word) - - log.info("Sensitive Data Miner Completed") - - -if __name__ == "__main__": - log.warning( - "Sensitive Data Miner Initialized. Processing may take a while... (Consider a break: coffee or fresh air recommended!)") - Mine.passwords() diff --git a/CODE/ssh_miner.py b/CODE/ssh_miner.py deleted file mode 100644 index 567cc539..00000000 --- a/CODE/ssh_miner.py +++ /dev/null @@ -1,47 +0,0 @@ -import os -import shutil - -from logicytics import log - - -@log.function -def ssh_miner(): - """ - This function backs up SSH keys and configuration - by copying them from the default SSH directory to a subdirectory - named 'ssh_backup' in the current working directory. - - Returns: - None - """ - # Get the current working directory - current_dir = os.getcwd() - - # Define the path to the SSH directory - ssh_folder = os.path.join(os.environ["USERPROFILE"], ".ssh") - - # Define the destination directory as the current working directory - destination_dir = current_dir - - # Ensure the destination directory exists - if not os.path.exists(destination_dir): - os.makedirs(destination_dir) - - # Define source and destination directories - source_dir = ssh_folder - destination_dir = os.path.join( - current_dir, "ssh_backup" - ) # Use a subdirectory named 'ssh_backup' in the current directory - - # Copy SSH keys and config - try: - shutil.copytree(source_dir, destination_dir) - log.info("SSH keys and configuration backed up successfully.") - except Exception as e: - log.error(f"Failed to back up SSH keys and configuration: {e}") - - log.info("SSH Miner completed.") - - -if __name__ == "__main__": - ssh_miner() diff --git a/CODE/sys_internal.py b/CODE/sys_internal.py deleted file mode 100644 index da71bea3..00000000 --- a/CODE/sys_internal.py +++ /dev/null @@ -1,93 +0,0 @@ -import os -import subprocess - -from logicytics import log - -sys_internal_executables = [ - "psfile.exe", - "PsGetsid.exe", - "PsInfo.exe", - "pslist.exe", - "PsLoggedon.exe", - "psloglist.exe", -] - -# Check if the executables exist -sys_internal_executables = [ - exe for exe in sys_internal_executables - if os.path.exists(os.path.join("SysInternal_Suite", exe)) -] - - -@log.function -def sys_internal(): - """ - This function runs a series of system internal sys_internal_executables and logs their output. - - It iterates over a list of executable names, constructs the command to run each one, - captures the output, and writes it to a file named 'SysInternal.txt'. - - The function also logs information and warning messages for each executable, - including any errors that occur during execution. - """ - with open("SysInternal.txt", "a") as outfile: - # Iterate over each executable - for executable in sys_internal_executables: - try: - # Construct the command to run the executable - command = f"{os.path.join('SysInternal_Suite', executable)}" - - # Execute the command and capture the output - result = subprocess.run( - command, stdout=subprocess.PIPE, stderr=subprocess.PIPE - ) - - # Write the output to the File - outfile.write("-" * 190) - outfile.write(f"{executable} Output:\n{result.stdout.decode()}") - log.info(f"{executable}: Successfully executed") - - # Optionally, handle errors if any - if ( - result.stderr.decode() != "" - and result.returncode != 0 - and result.stderr.decode() is not None - ): - log.warning(f"{executable}: {result.stderr.decode()}") - outfile.write(f"{executable}:\n{result.stderr.decode()}") - - except Exception as e: - log.error(f"Error executing {executable}: {str(e)}") - outfile.write(f"Error executing {executable}: {str(e)}\n") - log.info("SysInternal Suite fully executed") - - -def check_sys_internal_dir() -> tuple[bool, bool]: - """ - Checks the existence of the 'SysInternal_Suite' directory and its contents. - - Returns: - tuple[bool, bool]: A tuple where the first element is True if any of the - sys_internal_executables exist in the 'SysInternal_Suite' directory, and - the second element is True if 'SysInternal_Suite.zip' exists in the directory. - """ - if os.path.exists("SysInternal_Suite"): - return any( - os.path.exists(f"SysInternal_Suite/{file}") - for file in sys_internal_executables - ), os.path.exists("SysInternal_Suite/SysInternal_Suite.zip") - else: - log.error( - "SysInternal_Suite cannot be found as a directory, force closing the sys_internal.py program, continuing Logicytics" - ) - return False, False - - -if __name__ == "__main__": - if check_sys_internal_dir()[0]: - sys_internal() - elif check_sys_internal_dir()[1]: - log.warning( - "Files are not found, They are still zipped, most likely due to a .ignore file being present, continuing Logicytics") - else: - log.error("Files are not found, The zip file is also missing!, continuing Logicytics") diff --git a/CODE/tasklist.py b/CODE/tasklist.py deleted file mode 100644 index 5f135453..00000000 --- a/CODE/tasklist.py +++ /dev/null @@ -1,33 +0,0 @@ -import subprocess - -from logicytics import log - - -@log.function -def tasklist(): - """ - Retrieves a list of running tasks on the system and exports the result to a CSV file. - - Parameters: - None - - Returns: - None - """ - try: - result = subprocess.run( - "tasklist /v /fo csv", - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - with open("tasks.csv", "wb") as file: - file.write(result.stdout) - log.info("Tasklist exported to tasks.csv") - except subprocess.CalledProcessError as e: - log.error(f"Subprocess Error: {e}") - except Exception as e: - log.error(f"Error: {e}") - - -if __name__ == "__main__": - tasklist() diff --git a/CODE/tree.ps1 b/CODE/tree.ps1 deleted file mode 100644 index d19fde86..00000000 --- a/CODE/tree.ps1 +++ /dev/null @@ -1,9 +0,0 @@ -Write-Host "INFO: Starting Tree Command" - -# Define the output file name as Tree.txt -$outputFile = "Tree.txt" - -# Run the tree command and redirect the output to the file -tree /f C:\ | Out-File -FilePath $outputFile -Force - -Write-Host "INFO: Saved $outputFile" \ No newline at end of file diff --git a/CODE/usb_history.py b/CODE/usb_history.py deleted file mode 100644 index 912823ce..00000000 --- a/CODE/usb_history.py +++ /dev/null @@ -1,89 +0,0 @@ -import ctypes -import os -import winreg -from datetime import datetime, timedelta - -from logicytics import log - - -class USBHistory: - def __init__(self): - self.history_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "usb_history.txt") - - def _save_history(self, message: str): - """Append a timestamped message to the history file and log it.""" - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - entry = f"{timestamp} - {message}\n" - try: - with open(self.history_path, "a", encoding="utf-8") as f: - f.write(entry) - log.debug(f"Saved entry: {message}") - except Exception as e: - log.error(f"Failed to write history: {e}") - - # noinspection PyUnresolvedReferences - @staticmethod - def _get_last_write_time(root_key, sub_key_path): - """Return the precise last write time of a registry key, or None on failure.""" - handle = ctypes.wintypes.HANDLE() - try: - advapi32 = ctypes.windll.advapi32 - if advapi32.RegOpenKeyExW(root_key, sub_key_path, 0, winreg.KEY_READ, ctypes.byref(handle)) != 0: - return None - ft = ctypes.wintypes.FILETIME() - if advapi32.RegQueryInfoKeyW(handle, None, None, None, None, None, None, None, None, None, None, - ctypes.byref(ft)) != 0: - return None - t = ((ft.dwHighDateTime << 32) + ft.dwLowDateTime) // 10 - return datetime(1601, 1, 1) + timedelta(microseconds=t) - finally: - if handle: - ctypes.windll.advapi32.RegCloseKey(handle) - - @staticmethod - def _enum_subkeys(root, path, warn_func): - """Yield all subkeys of a registry key, logging warnings on errors.""" - try: - with winreg.OpenKey(root, path) as key: - subkey_count, _, _ = winreg.QueryInfoKey(key) - for i in range(subkey_count): - try: - yield winreg.EnumKey(key, i) - except OSError as e: - if getattr(e, "winerror", None) == 259: # ERROR_NO_MORE_ITEMS - break - warn_func(f"Error enumerating {path} index {i}: {e}") - except OSError as e: - warn_func(f"Failed to open registry key {path}: {e}") - - @staticmethod - def _get_friendly_name(dev_info_path, device_id): - """Return the friendly name of a device if available, else the device ID.""" - try: - with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, dev_info_path) as dev_key: - return winreg.QueryValueEx(dev_key, "FriendlyName")[0] - except FileNotFoundError: - return device_id - except Exception as e: - log.warning(f"Failed to read friendly name for {dev_info_path}: {e}") - return device_id - - def read(self): - """Read all USB devices from USBSTOR and log their info.""" - log.info("Starting USB history extraction...") - reg_path = r"SYSTEM\CurrentControlSet\Enum\USBSTOR" - try: - for device_class in self._enum_subkeys(winreg.HKEY_LOCAL_MACHINE, reg_path, log.warning): - dev_class_path = f"{reg_path}\\{device_class}" - for device_id in self._enum_subkeys(winreg.HKEY_LOCAL_MACHINE, dev_class_path, log.warning): - dev_info_path = f"{dev_class_path}\\{device_id}" - friendly_name = self._get_friendly_name(dev_info_path, device_id) - last_write = self._get_last_write_time(winreg.HKEY_LOCAL_MACHINE, dev_info_path) or "Unknown" - self._save_history(f"USB Device Found: {friendly_name} | LastWriteTime: {last_write}") - log.info(f"USB history extraction complete, saved to {self.history_path}") - except Exception as e: - log.error(f"Error during USB history extraction: {e}") - - -if __name__ == "__main__": - USBHistory().read() diff --git a/CODE/vulnscan.py b/CODE/vulnscan.py deleted file mode 100644 index b31a83ad..00000000 --- a/CODE/vulnscan.py +++ /dev/null @@ -1,196 +0,0 @@ -import csv -import json -import os -import shutil -from concurrent.futures import ThreadPoolExecutor, as_completed - -import torch -from sentence_transformers import SentenceTransformer -from torch import nn - -from logicytics import log, config - -# ================== GLOBAL SETTINGS ================== - -# File scan settings -TEXT_EXTENSIONS = { - ".txt", ".log", ".csv", ".json", ".xml", ".html", ".md", ".cfg", ".ini", ".yml", ".yaml", - ".rtf", ".tex", ".rst", ".adoc", ".properties", ".conf", ".bat", ".ps1", ".sh", ".tsv", - ".dat", ".env", ".toml", ".dockerfile", ".gitignore", ".gitattributes", ".npmrc", ".editorconfig" -} -MAX_TEXT_LENGTH = config.get("VulnScan Settings", "text_char_limit", fallback=None) -MAX_TEXT_LENGTH = int(MAX_TEXT_LENGTH) if MAX_TEXT_LENGTH not in (None, "None", "") else None -# Threading -NUM_WORKERS = config.get("VulnScan Settings", "max_workers", fallback="auto") -NUM_WORKERS = min(32, (os.cpu_count() or 1) * 2) if NUM_WORKERS == "auto" else int(NUM_WORKERS) -# Classification threshold -SENSITIVE_THRESHOLD = float( - config.get("VulnScan Settings", "threshold", fallback=0.6)) # Probability cutoff to consider a file sensitive - -# Paths -SENSITIVE_PATHS = [ - r"C:\Users\%USERNAME%\Documents", - r"C:\Users\%USERNAME%\Desktop", - r"C:\Users\%USERNAME%\Downloads", - r"C:\Users\%USERNAME%\AppData\Roaming", - r"C:\Users\%USERNAME%\AppData\Local", - r"C:\Users\%USERNAME%\OneDrive", - r"C:\Users\%USERNAME%\Dropbox", - r"C:\Users\%USERNAME%\Google Drive", -] -SAVE_DIR = r"VulnScan_Files" # Backup folder -MODEL_PATH = r"vulnscan/Model_SenseMacro.4n1.pth" # Your trained model checkpoint -REPORT_JSON = "report.json" -REPORT_CSV = "report.csv" - -# ================== DEVICE SETUP ================== -DEVICE = "cuda" if torch.cuda.is_available() else "cpu" -log.debug(f"Using device: {DEVICE}") - - -# ================== MODEL DEFINITION ================== -class SimpleNN(nn.Module): - def __init__(self, input_dim): - super().__init__() - self.fc = nn.Sequential( - nn.Linear(in_features=input_dim, out_features=256), - nn.ReLU(), - nn.Linear(in_features=256, out_features=64), - nn.ReLU(), - nn.Linear(in_features=64, out_features=1), - ) - - def forward(self, x): - return self.fc(x) - - -# ================== LOAD MODELS ================== -# Load classifier -checkpoint = torch.load(MODEL_PATH, map_location=DEVICE) -model = SimpleNN(input_dim=384) -model.load_state_dict(checkpoint["model_state_dict"]) -model.to(DEVICE) -model.eval() - -# Load embedding model -embed_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device=DEVICE) - -# Make backup folder -os.makedirs(SAVE_DIR, exist_ok=True) - - -# ================== FILE PROCESSING ================== -def process_file(filepath): - try: - _, ext = os.path.splitext(filepath) - if ext.lower() not in TEXT_EXTENSIONS: - return None - - with open(filepath, "r", encoding="utf-8", errors="ignore") as f_: - content = f_.read() - if not content.strip(): - return None - - # Limit file length - if MAX_TEXT_LENGTH is not None: - content = content[:MAX_TEXT_LENGTH] - - # Split content into lines - lines = [line_ for line_ in content.splitlines() if line_.strip()] - if not lines: - return None - - # Embed all lines - embeddings = embed_model.encode(lines, convert_to_tensor=True, device=DEVICE) - - # Predict per line - probs = [] - for emb in embeddings: - with torch.no_grad(): - output = model(emb.unsqueeze(0)) - probs.append(torch.sigmoid(output).item()) - - max_prob = max(probs) - if max_prob < SENSITIVE_THRESHOLD: - return None - - # Get top 5 lines contributing most - top_lines = [lines[i] for i, p in sorted(enumerate(probs), key=lambda x: x[1], reverse=True)[:5]] - - # Backup file - rel_path = os.path.relpath(filepath, ROOT_DIR) - backup_path = os.path.join(SAVE_DIR, rel_path) - os.makedirs(os.path.dirname(backup_path), exist_ok=True) - shutil.copy2(filepath, backup_path) - - return { - "file": filepath, - "probability": max_prob, - "copied_to": backup_path, - "reason": top_lines - } - - except Exception as e: - log.error(f"Could not process {filepath}: {e}") - return None - - -# ================== DIRECTORY SCAN ================== -def scan_directory(root): - sensitive_files = [] - with ThreadPoolExecutor(max_workers=NUM_WORKERS) as executor: - futures = [] - for dirpath, _, filenames in os.walk(root): - for file in filenames: - futures.append(executor.submit(process_file, os.path.join(dirpath, file))) - - for future in as_completed(futures): - result = future.result() - if result: - sensitive_files.append(result) - - return sensitive_files - - -# ================== MAIN ================== -def main(): - log.info(f"Scanning directory: {ROOT_DIR} - This will take some time...") - sensitive = scan_directory(ROOT_DIR) - - # Save JSON report - with open(REPORT_JSON, "w", encoding="utf-8") as f: - json.dump(sensitive, f, indent=2, ensure_ascii=False) - - # Save CSV report - with open(REPORT_CSV, "w", newline="", encoding="utf-8") as f: - writer = csv.DictWriter(f, fieldnames=["file", "probability", "copied_to", "reason"]) - writer.writeheader() - for entry in sensitive: - # Join top lines as single string for CSV - entry_csv = entry.copy() - entry_csv["reason"] = " | ".join(entry["reason"]) - writer.writerow(entry_csv) - - print() - log.debug("Sensitive files detected and backed up:") - for entry in sensitive: - log.debug(f" - {entry['file']} (prob={entry['probability']:.4f})") - for line in entry["reason"]: - log.debug(f" -> {line}") - - print() - log.info("Backup completed.\n") - log.debug(f"Files copied into: {SAVE_DIR}") - log.debug(f"JSON report saved as: {REPORT_JSON}") - log.debug(f"CSV report saved as: {REPORT_CSV}") - - -if __name__ == "__main__": - log.info(f"Starting VulnScan with {NUM_WORKERS} thread workers and {len(SENSITIVE_PATHS)} paths...") - for path in SENSITIVE_PATHS: - expanded_path = os.path.expandvars(path) - if os.path.exists(expanded_path): - ROOT_DIR = expanded_path - main() - else: - log.warning(f"Path does not exist and will be skipped: {expanded_path}") diff --git a/CODE/vulnscan/Model_SenseMacro.4n1.pth b/CODE/vulnscan/Model_SenseMacro.4n1.pth deleted file mode 100644 index 4f8182cc7d98f678d4646e7a1bc1fff92c8a73b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1389747 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%0Dt1Cc+r3}#B0!40FcaQf(R96SFH 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 c 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 nrq@zArT?ye< str | None: - """ - Retrieves the password for a specified Wi-Fi network. - - Args: - ssid (str): The name (SSID) of the Wi-Fi network to retrieve the password for. - - Returns: - str or None: The Wi-Fi network password if found, otherwise None. - - Raises: - Exception: If an error occurs during command execution or password retrieval. - - Notes: - - Uses the Windows `netsh` command to extract network profile details - - Searches command output for "Key Content" to find the password - - Logs any errors encountered during the process - """ - try: - command_output = execute.command( - f'netsh wlan show profile name="{ssid}" key=clear' - ) - if command_output: - key_content = command_output.splitlines() - for line in key_content: - if "Key Content" in line: - return line.split(":")[1].strip() - return None - except Exception as err: - log.error(err) - return None - - -def parse_wifi_names(command_output: str) -> list: - """ - Parses the output of the command to extract Wi-Fi profile names. - - Args: - command_output (str): The output of the command "netsh wlan show profile" containing Wi-Fi profile information. - - Returns: - list: A list of extracted Wi-Fi profile names, stripped of whitespace. - """ - wifi_names = [] - - for line in command_output.split("\n"): - if "All User Profile" in line: - start_index = line.find("All User Profile") + len("All User Profile") - wifi_name = line[start_index:].strip() - wifi_names.append(wifi_name) - - return wifi_names - - -def get_wifi_names() -> list: - """ - Retrieves the names of all Wi-Fi profiles on the system. - - Executes the "netsh wlan show profile" command to list available Wi-Fi network profiles. - Parses the command output to extract individual profile names. - - Returns: - list: A list of Wi-Fi network profile names discovered on the system. - - Raises: - Exception: If an error occurs during the retrieval of Wi-Fi names. - - Example: - wifi_profiles = get_wifi_names() # Returns ['HomeNetwork', 'CoffeeShop', ...] - """ - try: - log.info("Retrieving Wi-Fi names...") - wifi_names = parse_wifi_names(execute.command("netsh wlan show profile")) - log.info(f"Retrieved {len(wifi_names)} Wi-Fi names.") - return wifi_names - except Exception as err: - log.error(err) - return [] - - -@log.function -def get_wifi_passwords(): - """ - Retrieves the passwords for all Wi-Fi profiles on the system. - - This function retrieves the names of all Wi-Fi profiles on the system using the get_wifi_names() function. - It then iterates over each Wi-Fi profile name and retrieves the password associated with the profile using the get_password() function. - The Wi-Fi profile names and passwords are stored in a dictionary where the key is the Wi-Fi profile name and the value is the password. - """ - with open("WiFi.txt", "w") as file: - for name in get_wifi_names(): - try: - log.info(f"Retrieving password for {name.removeprefix(': ')}") - file.write( - f"Name: {name.removeprefix(': ')}, Password: {get_password(name.removeprefix(': '))}\n" - ) - except UnicodeDecodeError as e: - log.error(e) - except Exception as e: - log.error(e) - - -if __name__ == "__main__": - get_wifi_passwords() diff --git a/CODE/window_feature_miner.ps1 b/CODE/window_feature_miner.ps1 deleted file mode 100644 index 83832b77..00000000 --- a/CODE/window_feature_miner.ps1 +++ /dev/null @@ -1,3 +0,0 @@ -# List all optional features and save them to Features.txt -Write-Output "INFO: Starting Feature mining, saving to Features.txt" -Get-WindowsOptionalFeature -Online | Format-Table -Property FeatureName, State > Features.txt diff --git a/CODE/wmic.py b/CODE/wmic.py deleted file mode 100644 index 8978bf41..00000000 --- a/CODE/wmic.py +++ /dev/null @@ -1,40 +0,0 @@ -from logicytics import log, execute - - -@log.function -def wmic(): - """ - Retrieves system information using WMIC commands. - - This function runs a series of WMIC commands to gather information about the system's BIOS, - operating system, computer system, and disk drives. - The output of each command is written to a file named "wmic_output.txt". - - Parameters: - None - - Returns: - None - """ - data = execute.command("wmic BIOS get Manufacturer,Name,Version /format:htable") - with open("WMIC.html", "w") as file: - file.write(data) - wmic_commands = [ - "wmic os get Caption,CSDVersion,ServicePackMajorVersion", - "wmic computersystem get Model,Manufacturer,NumberOfProcessors", - "wmic BIOS get Manufacturer,Name,Version", - "wmic diskdrive get model,size", - ] - with open("wmic_output.txt", "w") as file: - for index, command in enumerate(wmic_commands): - log.info(f"Executing Command Number {index + 1}: {command}") - output = execute.command(command) - file.write("-" * 190) - file.write(f"Command {index + 1}: {command}\n") - file.write(output) - - file.write("-" * 190) - - -if __name__ == "__main__": - wmic() diff --git a/MODS/_MOD_SKELETON.py b/MODS/_MOD_SKELETON.py deleted file mode 100644 index c77a6ecd..00000000 --- a/MODS/_MOD_SKELETON.py +++ /dev/null @@ -1,54 +0,0 @@ -# If using the future annotations, it should be ontop of the file -# from __future__ import annotations - -# Other Imports if needed or necessary go here - -# To know more check the WiKi -from logicytics import log # And more if needed - - -# Your actual code, must be able to run without any interference by outside actions -# USE log.debug, log.info, log.error, log.warning and log.critical and log.string as well -# You can choose to use any other of the code without issues -# Example of said code:- - - -# This log decorator logs the function name and the time it took to run, -# It is recommended to use this, -# as it only logs the function and the time it took to run -# in debug mode thus helping when people enable debug mode -# Do note however, if you are using multiple decorators, this should be the last one -# check the WiKi for more information -# Do not use this decorator if you are running a function that is part of another function -@log.function -def MOD_EXAMPLE() -> None: - """ - This function MOD is used to log different types of messages. - - It logs an error message, a warning message, an info message, and a debug message. - - Parameters: - None - - Returns: - None - """ - log.error("This is an error") - log.warning("This is a warning") - log.info("This is a info message") - log.debug("This is a debug message") - log.critical("This is a critical message") - # This is special, allows you to use strings to specify the log level, it is not recommended to use these - # Options are error, warning, info, debug, critical - It is case-insensitive and can be used with any of the log levels - # Defaults with the log level of debug - log.string("This is a random message", "ERROR") - pass # Your code here with proper logging like the above log options - - -# It is recommended to call your function at the end of the file using the following code -# This is to ensure that the function is called only when directly executed and not when imported -if __name__ == "__main__": - MOD_EXAMPLE() - -# Always remember to call your function at the end of the file and then leave a new line -# This is to ensure that the function is called and the file is not empty diff --git a/core/.gitkeep b/core/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/core/.gitkeep @@ -0,0 +1 @@ + diff --git a/logicytics/.gitkeep b/logicytics/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/logicytics/.gitkeep @@ -0,0 +1 @@ + diff --git a/plugins/.gitkeep b/plugins/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/plugins/.gitkeep @@ -0,0 +1 @@ + From 61317867750d3d177d5e9232cbfa7a5a09db4ee7 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:31:19 +0300 Subject: [PATCH 002/316] feat: add isolated v4 collection engine --- README.md | 344 ++++++++---------------------- core/.gitkeep | 1 - core/system/system_info.py | 71 +++++++ logicytics/__init__.py | 27 +++ logicytics/__main__.py | 5 + logicytics/artifacts.py | 78 +++++++ logicytics/cli.py | 94 +++++++++ logicytics/configuration.py | 85 ++++++++ logicytics/contracts.py | 248 ++++++++++++++++++++++ logicytics/discovery.py | 260 +++++++++++++++++++++++ logicytics/errors.py | 19 ++ logicytics/logging.py | 34 +++ logicytics/manifest.py | 122 +++++++++++ logicytics/packaging.py | 46 ++++ logicytics/planner.py | 92 ++++++++ logicytics/runtime.py | 352 +++++++++++++++++++++++++++++++ logicytics/validation_worker.py | 70 ++++++ pyproject.toml | 16 ++ requirements.txt | 22 +- tests/__init__.py | 1 + tests/test_core_functionality.py | 150 +++++++++++++ 21 files changed, 1864 insertions(+), 273 deletions(-) delete mode 100644 core/.gitkeep create mode 100644 core/system/system_info.py create mode 100644 logicytics/__init__.py create mode 100644 logicytics/__main__.py create mode 100644 logicytics/artifacts.py create mode 100644 logicytics/cli.py create mode 100644 logicytics/configuration.py create mode 100644 logicytics/contracts.py create mode 100644 logicytics/discovery.py create mode 100644 logicytics/errors.py create mode 100644 logicytics/logging.py create mode 100644 logicytics/manifest.py create mode 100644 logicytics/packaging.py create mode 100644 logicytics/planner.py create mode 100644 logicytics/runtime.py create mode 100644 logicytics/validation_worker.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/test_core_functionality.py diff --git a/README.md b/README.md index 19b25e9f..3ad919c0 100644 --- a/README.md +++ b/README.md @@ -1,252 +1,92 @@ -# Logicytics: System Data Harvester - -Logicytics is a cutting-edge tool designed to -meticulously harvest and collect a vast array of Windows system data for forensic analysis. -Crafted with Python, it's an actively developed project that is dedicated -to gathering as much sensitive data as possible and packaging it neatly into a ZIP file. -This comprehensive guide is here to equip you with everything you need to use Logicytics effectively. - -

- GitHub Issues - GitHub Tag - GitHub Commit Activity - GitHub Language Count - GitHub Branch Check Runs - GitHub Repo Size -
-
- GitHub Repo CodeFactor Rating - Maintainability - OpenSSF Best Practices Score - OpenSSF Best Practices Badge -
- -> [!CAUTION] -> By using this software, you agree to the license, and agree that you hold responsibility of how you use and modify the -> code. - -## Installation and Setup - -To install and setup Logicytics, follow these steps: - -1. **Install Python**: If you don't have Python installed, you can download it from - the [official website](https://www.python.org/downloads/). - -2. **Install Dependencies**: Logicytics requires Python modules. You can install all the required modules by running the - following command in your terminal: `pip install -r requirements.txt` - -3. **Run Logicytics**: To run Logicytics, simply run the following command in your terminal: `python Logicytics.py -h` - - This opens a help menu. - -> [!IMPORTANT] -> We recommend Python Version `3.11` or higher, as the project is developed and tested on this version. -> -> To use vulnscan, you will need `torch` - Installation instructions can be -> found [here](https://pytorch.org/#fws_68845ae25b0fb). -> If you have a supported GPU, it is recommended to install the Nvidea GPU version of PyTorch for better performance. -> -> Settings should be: `Stable -> Windows -> Pip -> Python` and if you have a supported CUDA version, select that too -> else CPU. - -### Prerequisites - -- **Python**: The project requires Python 3.8 or higher. You can download Python from - the [official website](https://www.python.org/downloads/). - -- **Dependencies**: The project requires certain Python modules to be installed. You can install all the required - modules by running the following command in your terminal: `pip install -r requirements.txt`. - -- **Administrative Privileges**: To be able to run the program using certain features of the project, like registry - modification, you must run the program with administrative privileges. - -- **System Requirements**: The project has been tested on Windows 10 and 11. It will not work on other operating - systems. - -- **Knowledge of Command Line**: The project uses command line options for the user to interact with the program. It is - recommended to have a basic understanding of command line options. - -> [!IMPORTANT] -> You may create a `.sys.ignore` file in the `CODE/SysInternal_Suite` directory to not extract the exe binaries from the -> ZIP file (This is done for the OpenSSF score and to discourage binaries being used without source code), if the -`.sys.ignore` file is not found, it will auto extract the binaries and run them using `Logicytics`. -> -> For more details on these binaries, -> go [here](https://learn.microsoft.com/en-us/sysinternals/downloads/sysinternals-suite) - For you weary cautious -> internet -> crusaders, you can view the [source code here](https://github.com/MicrosoftDocs/sysinternals) and compare hashes and -> perform your audits. - -## Step-by-Step Installation and Usage - -1) Install Python - If you don't have Python installed, you can download it from the official - website. - Make sure to select the option to "Add Python to PATH" during installation. - -2) Install Dependencies - Logicytics requires Python modules. You can install all the required modules by running the following command in your - terminal: - `pip install -r requirements.txt` - -3) Run Logicytics - To run Logicytics, simply run the following command in your terminal: - python Logicytics.py -h - This opens a help menu. - -4) Run the Program - Once you have run the program, you can run the program with the following command: - `python Logicytics.py -h` - Replace the flags with the ones you want to use. - you must have admin privileges while running! - -> [!TIP] -> Although it's really recommended to use admin, by setting debug in the config.json to true, you can bypass this -> requirement - -5) Wait for magic to happen - Logicytics will now run and gather data according to the flags you used. - -6) Enjoy the gathered data - Once the program has finished running, you can find the gathered data in the "ACCESS/DATA" folder. Both Zip and Hash - will be found there. - -> [!NOTE] -> All Zips and Hashes follow a conventional naming mechanism that goes as follows -> `Logicytics_{CODE-or-MODS}_{Flag-Used}_{Date-And-Time}.zip` - -7) Share the love - If you like Logicytics, please consider sharing it with others or spreading the word about it. - -8) Contribute to the project - If you have an idea or want to contribute to the project, you can submit an issue or PR on - the GitHub repository. - -### Basic Usage - -After running and successfully collecting data, you may traverse the ACCESS directory as much as you like, -Remove add and delete files, it's the safe directory where your backups, hashes, data zips and logs are found. - -> [!TIP] -> Watch this [video](https://www.youtube.com/watch?v=XVTBmdTQqOs) to see a real life demo of Logicytics (Although the -> tools and interface may be changed as it's an older version `2.1.1` - `2.3.3`) - -## Configuration - -Logicytics uses a config.ini file to store configurations. The config.ini is located in the CODE directory. - -The config.ini file is a INI file that contains important information, you can find it [here](CODE/config.ini) - -The config.ini file is used to store the DEBUG flag bool, the VERSION, and the CURRENT_FILES. -It is also used to store and save settings for other programs. - -> [!TIP] -> CURRENT_FILES is an array of strings that contains the names of the files you have, -> this is used to later check for corruption or bugs. -> VERSION is the version of the project, used to check and pull for updates. - -## Mods - -Mods are special files that are run with the `--modded` flag. -These files are essentially scripts that are run after the main Logicytics.py script is run -and the verified scripts are run. - -They are used to add extra functionality to the script. -They are located in the `MODS` directory. In order to make a mod, -you need to create a python file with the `.py` extension or any of the supported extensions `.exe .ps1 .bat` -in the `MODS` directory. - -These file will be run after the main script is run. -When making a mod, you should avoid acting based on other files directly, -as this can cause conflicts with the data harvesting. -Instead, you should use the `Logicytics.py` file and other scripts as a reference -for how to add features to the script. - -The `--modded` flag is used to run all files in the `MODS` directory. -This flag is not needed for other files in the `CODE` directory to run, -but it is needed for mods to run. - -The `--modded` flag can also be used to run custom scripts. -If you want to run a custom script with the `--modded` flag, -you can add the script to the `MODS` directory, and it will be run with the `--modded` flag. - -To check all the mods and how to make your own, you can check the `Logicytics.py` file and the Wiki. -Also refer to the contributing.md for more info - -## Troubleshooting - -If you are having issues, here are some troubleshooting tips: - -Some errors may not necessarily mean the script is at fault, -but other OS related faults like files not existing, -or files not being modified, or files not being created. - -Some tips are: - -- Check if the script is running as admin and not in a VM -- Check if the script has the correct permissions and correct dependencies to run -- Check if the script is not being blocked by a firewall or antivirus or by a VPN or proxy -- Check if the script is not being blocked by any other software or service - -If those don't work attempt: - -- Try running the script with powershell instead of cmd, or vice versa -- Try running the script in a different directory, computer or python version above 3.8 - - Note: The version used to develop, test and run the script is 3.11 -- Try running the `--debug` flag and check the logs - -### Support Resources - -Check out the [wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) for help. - -> [!TIP] -> You can check out future plans [here](PLANS.md), -> you can contribute these plans if you have no idea's on what to contribute! - -### Want to create your own mod? - -Check out the [contributing guidlines](CONTRIBUTING.md) file for more info - -### Want More? - -If there is a specific piece of data that you would like to see extracted by Logicytics, -please let us know. We are constantly working to improve the project and adding new features. - -### Want to create your own mod? - -Check out the [contributing guidlines](CONTRIBUTING.md) file for more info, -as well as the [wiki guidelines](https://github.com/DefinetlyNotAI/Logicytics/wiki/5-How-to-Contribute) for more info -Tips and tricks of the given modules/APIs can be -found [here](https://github.com/DefinetlyNotAI/Logicytics/wiki/6-Code-tips-and-tricks) too! - -> [!IMPORTANT] -> Always adhere to the [coding standards](https://github.com/DefinetlyNotAI/Logicytics/wiki/7-Advanced-Coding-Standards) -> of Logicytics! - -## Conclusion - -Logicytics is a powerful tool that can extract a wide variety of data from a Windows system. -With its ability to extract data from various sources, Logicytics can be used for a variety of purposes, -from forensics to system information gathering. -Its ability to extract data from various sources makes it a valuable tool -for any Windows system administrator or forensic investigator. - -> [!CAUTION] -> Please remember that extracting data from a system without proper authorization is illegal and unethical. -> Always obtain proper authorization before extracting any data from a system. - -## Support Me - -Please consider buying me a coffee or sponsoring me in GitHub sponsor, -I am saving for my college funds, and I need your help! -Supporters will be placed in the Credits ❤️ - -### Links - -- [Project's Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) -- [Project's Future](PLANS.md) -- [Project's License](LICENSE) - -### License - -- [Developer Certificate of Origin](DCO.md) -- [Our License](LICENSE) +# Logicytics v4 + +Logicytics v4 is a Windows-focused, run-oriented evidence collection framework. +It is being rebuilt from a clean architecture around an explicit pipeline: + +`request -> validated plan -> isolated collectors -> registered artifacts -> manifest -> package` + +The project is currently an early v4 implementation. The engine and the first +safe system collector are available; the broader collection catalog is being +rebuilt incrementally. + +## Current capabilities + +- Typed `core/` and `plugins/` collector contracts. +- Strict preflight validation before a collector can run. +- One process and private workspace per collector. +- Explicit capability approval and collection authorization. +- Structured JSONL engine and collector logs. +- Artifact registration with workspace boundaries, output limits, SHA-256 hashes, + a run manifest, ZIP package, and package hash. +- First shipped collector: `core.system.system_info`. + +## Layout + +```text +core/ + system/ + system_info.py # shipped collectors +plugins/ # user-created collectors, validated separately +logicytics/ # engine, contracts, planning, runtime, and packaging +tests/ # core integration tests +ACCESS/ # ignored generated runs and packages +``` + +## Requirements + +- Python 3.11 or later. +- Windows for the currently shipped collector and future Windows integrations. + +The v4 core uses only the Python standard library. A collector may later declare +its own dependency and capability requirements. + +## Commands + +Run these from the repository root: + +```powershell +python -m logicytics preflight +python -m logicytics plan --profile standard +python -m logicytics run --profile standard --acknowledge-authorization +``` + +`preflight` reports valid and quarantined collectors. `plan` resolves the exact +collector order without collecting data. `run` requires an explicit authorization +acknowledgement and writes results below `ACCESS/RUNS/`, with ZIP packages and +SHA-256 sidecars under `ACCESS/PACKAGES/`. + +Collectors that request additional access must be explicitly approved, for +example: + +```powershell +python -m logicytics run --profile standard --acknowledge-authorization ` + --allow-capability filesystem_read +``` + +## Collector rules + +Core collectors must live at `core//.py`. Each module +must expose exactly one public class named `Collector`, inherit +from `CoreCollector`, provide documented and typed `metadata`, `validate`, +`collect`, and `cleanup` methods, and use a matching ID/specialty. Plugins follow +the equivalent `PluginCollector` contract. + +Malformed core collectors block a run. Malformed unselected plugins are +quarantined and listed by preflight; malformed explicitly selected plugins block +the requested run. See [TODO.md](TODO.md) for the complete v4 recreation plan. + +## Verification + +```powershell +python -m unittest discover -v +python -m compileall -q logicytics core tests +``` + +## Authorization + +Use Logicytics only on systems and data you are authorized to inspect. The v4 +engine requires an explicit acknowledgement before it starts selected collectors. + +## License + +See [LICENSE](LICENSE). diff --git a/core/.gitkeep b/core/.gitkeep deleted file mode 100644 index 8b137891..00000000 --- a/core/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/core/system/system_info.py b/core/system/system_info.py new file mode 100644 index 00000000..7db1270c --- /dev/null +++ b/core/system/system_info.py @@ -0,0 +1,71 @@ +"""Collect a bounded, non-sensitive inventory of the current Windows system.""" + +from __future__ import annotations + +import getpass +import json +import os +import platform +import socket +from datetime import datetime, timezone + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext + + +class SystemInfoCollector(CoreCollector): + """Create a JSON system inventory through the v4 artifact boundary.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the collector identity, scope, and bounded output contract.""" + return CollectorMetadata( + id="core.system.system_info", + name="System information", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Collects a bounded operating-system and hardware inventory.", + author="Logicytics", + supported_platforms=("win32",), + default_profiles=("minimal", "standard", "deep", "offline"), + timeout_seconds=15, + maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Confirm that the standard-library system APIs are available.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Write and register the system inventory as a JSON evidence artifact.""" + context.report_progress("system_inventory_started") + inventory = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "hostname": socket.gethostname(), + "username": getpass.getuser(), + "operating_system": { + "system": platform.system(), + "release": platform.release(), + "version": platform.version(), + "architecture": platform.architecture(), + }, + "hardware": { + "machine": platform.machine(), + "processor": platform.processor() or "unavailable", + "cpu_count": os.cpu_count(), + }, + "python": platform.python_version(), + } + output = context.workspace / "system_info.json" + output.write_text(json.dumps(inventory, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("system_inventory_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded( + "system inventory collected", + (artifact,), + ) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because this collector owns none beyond its workspace.""" diff --git a/logicytics/__init__.py b/logicytics/__init__.py new file mode 100644 index 00000000..7f120eb8 --- /dev/null +++ b/logicytics/__init__.py @@ -0,0 +1,27 @@ +"""Logicytics v4 public engine API.""" + +from logicytics.contracts import ( + CONTRACT_VERSION, + Capability, + Collector, + CollectorMetadata, + CollectorResult, + CoreCollector, + PluginCollector, + RunRequest, + Specialty, + ValidationResult, +) + +__all__ = [ + "CONTRACT_VERSION", + "Capability", + "Collector", + "CollectorMetadata", + "CollectorResult", + "CoreCollector", + "PluginCollector", + "RunRequest", + "Specialty", + "ValidationResult", +] diff --git a/logicytics/__main__.py b/logicytics/__main__.py new file mode 100644 index 00000000..482339a8 --- /dev/null +++ b/logicytics/__main__.py @@ -0,0 +1,5 @@ +"""Enable `python -m logicytics`.""" + +from logicytics.cli import main + +raise SystemExit(main()) diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py new file mode 100644 index 00000000..b65062f5 --- /dev/null +++ b/logicytics/artifacts.py @@ -0,0 +1,78 @@ +"""Evidence artifact registration with workspace-bound path validation.""" + +from __future__ import annotations + +import hashlib +import shutil +from pathlib import Path +from uuid import uuid4 + +from logicytics.contracts import Artifact, ArtifactWriter +from logicytics.errors import ArtifactError + + +def _is_within(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + except ValueError: + return False + return True + + +def sha256_file(path: Path) -> str: + """Hash a file incrementally so large evidence does not fill memory.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +class WorkspaceArtifactWriter(ArtifactWriter): + """Copies approved files from one collector workspace to the run artifact tree.""" + + def __init__( + self, + collector_id: str, + workspace: Path, + artifact_root: Path, + maximum_output_bytes: int, + ) -> None: + self._collector_id = collector_id + self._workspace = workspace.resolve() + self._artifact_root = artifact_root.resolve() + self._maximum_output_bytes = maximum_output_bytes + self._bytes_registered = 0 + self._artifacts: list[Artifact] = [] + + @property + def artifacts(self) -> tuple[Artifact, ...]: + """Return artifacts registered by this worker in registration order.""" + return tuple(self._artifacts) + + def register_file(self, source: Path, *, media_type: str = "application/octet-stream") -> Artifact: + source = source.resolve() + if not source.is_file() or not _is_within(source, self._workspace): + raise ArtifactError("artifacts must be regular files inside the collector workspace") + size_bytes = source.stat().st_size + if self._bytes_registered + size_bytes > self._maximum_output_bytes: + raise ArtifactError("collector output exceeds its declared maximum_output_bytes") + + relative_source = source.relative_to(self._workspace) + safe_collector_id = self._collector_id.replace(".", "_") + destination = self._artifact_root / safe_collector_id / relative_source + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + destination = destination.with_name(f"{destination.stem}-{uuid4().hex[:8]}{destination.suffix}") + shutil.copy2(source, destination) + artifact = Artifact( + id=f"artifact.{uuid4().hex}", + relative_path=destination.relative_to(self._artifact_root).as_posix(), + sha256=sha256_file(destination), + size_bytes=size_bytes, + media_type=media_type, + collector_id=self._collector_id, + ) + self._bytes_registered += size_bytes + self._artifacts.append(artifact) + return artifact diff --git a/logicytics/cli.py b/logicytics/cli.py new file mode 100644 index 00000000..f7909863 --- /dev/null +++ b/logicytics/cli.py @@ -0,0 +1,94 @@ +"""Minimal command-line interface for v4 planning and supervised execution.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from logicytics.configuration import load_config +from logicytics.contracts import Capability, RunRequest +from logicytics.discovery import preflight +from logicytics.errors import LogicyticsError +from logicytics.packaging import package_run +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +def _project_root() -> Path: + return Path(__file__).resolve().parent.parent + + +def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: + return RunRequest( + profile=arguments.profile, + include=tuple(arguments.include), + exclude=tuple(arguments.exclude), + enable_plugins=arguments.plugins, + max_workers=arguments.workers or default_workers, + acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), + approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") + parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") + subcommands = parser.add_subparsers(dest="command", required=True) + for command in ("preflight", "plan", "run"): + subparser = subcommands.add_parser(command) + subparser.add_argument("--profile", default="standard") + subparser.add_argument("--include", action="append", default=[]) + subparser.add_argument("--exclude", action="append", default=[]) + subparser.add_argument("--plugins", action="store_true") + subparser.add_argument("--workers", type=int) + subparser.add_argument( + "--allow-capability", + action="append", + default=[], + choices=[capability.value for capability in Capability], + help="Approve an access capability requested by the selected collectors.", + ) + if command == "run": + subparser.add_argument( + "--acknowledge-authorization", + action="store_true", + help="Confirm you are authorized to collect the selected evidence.", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + """Run the selected preflight, planning, or supervised execution command.""" + arguments = _parser().parse_args(argv) + root = _project_root() + try: + configuration = load_config(root, arguments.config) + report = preflight(root) + if arguments.command == "preflight": + payload = { + "valid": [candidate.metadata.id for candidate in report.valid if candidate.metadata], + "invalid": [ + {"path": str(candidate.path), "errors": candidate.static_errors or [candidate.runtime_error]} + for candidate in report.invalid + ], + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if not report.invalid else 2 + plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) + if arguments.command == "plan": + print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) + return 0 + outcome = RunSupervisor(root, configuration).run(plan) + if configuration.runtime.package_completed_runs: + package_path, hash_path = package_run(outcome) + print(f"Package: {package_path}\nSHA-256: {hash_path}") + print(f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}") + return 0 if outcome.manifest.status.value == "succeeded" else 1 + except (LogicyticsError, PermissionError, ValueError) as error: + print(f"Error: {error}") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/logicytics/configuration.py b/logicytics/configuration.py new file mode 100644 index 00000000..e51f8d19 --- /dev/null +++ b/logicytics/configuration.py @@ -0,0 +1,85 @@ +"""Typed local configuration for v4 runs.""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Mapping + +from logicytics.errors import PlanError + + +@dataclass(frozen=True, slots=True) +class RuntimeSettings: + """Engine-wide limits that apply before a collector is started.""" + + output_root: Path + default_max_workers: int = 4 + maximum_workers: int = 16 + package_completed_runs: bool = True + + +@dataclass(frozen=True, slots=True) +class AppConfig: + """Validated settings loaded from an optional JSON configuration file.""" + + runtime: RuntimeSettings + collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) + + def settings_for(self, collector_id: str) -> Mapping[str, Any]: + """Return the isolated settings declared for one collector.""" + return self.collector_settings.get(collector_id, {}) + + def to_manifest_dict(self) -> dict[str, Any]: + """Return a JSON-safe, non-secret configuration snapshot.""" + data = asdict(self) + data["runtime"]["output_root"] = str(self.runtime.output_root) + return data + + +def default_config(project_root: Path) -> AppConfig: + """Create safe defaults rooted at the checked-out project.""" + return AppConfig(runtime=RuntimeSettings(output_root=project_root / "ACCESS" / "RUNS")) + + +def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: + """Load and validate `logicytics.json`, or use the documented defaults.""" + path = config_path or project_root / "logicytics.json" + if not path.exists(): + return default_config(project_root) + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise PlanError(f"invalid configuration file {path}: {error}") from error + if not isinstance(raw, dict): + raise PlanError("configuration root must be a JSON object") + + runtime_raw = raw.get("runtime", {}) + if not isinstance(runtime_raw, dict): + raise PlanError("runtime configuration must be an object") + output_root = Path(runtime_raw.get("output_root", project_root / "ACCESS" / "RUNS")) + if not output_root.is_absolute(): + output_root = project_root / output_root + default_workers = runtime_raw.get("default_max_workers", 4) + maximum_workers = runtime_raw.get("maximum_workers", 16) + if not isinstance(default_workers, int) or not isinstance(maximum_workers, int): + raise PlanError("worker limits must be integers") + if not 1 <= default_workers <= maximum_workers <= 64: + raise PlanError("worker limits must satisfy 1 <= default <= maximum <= 64") + + collector_settings = raw.get("collectors", {}) + if not isinstance(collector_settings, dict) or not all( + isinstance(key, str) and isinstance(value, dict) + for key, value in collector_settings.items() + ): + raise PlanError("collectors configuration must map collector IDs to objects") + return AppConfig( + runtime=RuntimeSettings( + output_root=output_root, + default_max_workers=default_workers, + maximum_workers=maximum_workers, + package_completed_runs=bool(runtime_raw.get("package_completed_runs", True)), + ), + collector_settings=collector_settings, + ) diff --git a/logicytics/contracts.py b/logicytics/contracts.py new file mode 100644 index 00000000..1718b913 --- /dev/null +++ b/logicytics/contracts.py @@ -0,0 +1,248 @@ +"""Stable v4 contracts shared by the engine, core collectors, and plugins.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import asdict, dataclass, field +from enum import StrEnum +from pathlib import Path +from typing import Any, Mapping + +CONTRACT_VERSION = "4.0" + + +class CollectorKind(StrEnum): + """The source tree that owns a collector.""" + + CORE = "core" + PLUGIN = "plugin" + + +class CollectorStatus(StrEnum): + """Terminal states reported for an individual collector.""" + + SUCCEEDED = "succeeded" + PARTIAL = "partial" + SKIPPED = "skipped" + CANCELLED = "cancelled" + FAILED = "failed" + + +class RunStatus(StrEnum): + """Terminal and in-progress states for a complete run.""" + + PLANNED = "planned" + RUNNING = "running" + SUCCEEDED = "succeeded" + PARTIAL = "partial" + FAILED = "failed" + CANCELLED = "cancelled" + + +class Specialty(StrEnum): + """The closed set of supported collector specialties.""" + + SYSTEM = "system" + HARDWARE = "hardware" + PROCESS = "process" + MEMORY = "memory" + FILESYSTEM = "filesystem" + NETWORK = "network" + PACKET = "packet" + WIRELESS = "wireless" + BLUETOOTH = "bluetooth" + USB = "usb" + BROWSER = "browser" + REGISTRY = "registry" + EVENT_LOG = "event_log" + STORAGE = "storage" + ENCRYPTION = "encryption" + MEDIA = "media" + SSH = "ssh" + DIAGNOSTICS = "diagnostics" + REPORTING = "reporting" + INTEGRATION = "integration" + + +class Capability(StrEnum): + """Explicitly approved platform access a collector may request.""" + + FILESYSTEM_READ = "filesystem_read" + REGISTRY_READ = "registry_read" + SUBPROCESS = "subprocess" + NETWORK = "network" + PACKET_CAPTURE = "packet_capture" + BROWSER_DATA = "browser_data" + SENSITIVE_FILES = "sensitive_files" + PRIVATE_KEYS = "private_keys" + ELEVATED_PRIVILEGES = "elevated_privileges" + + +@dataclass(frozen=True, slots=True) +class CollectorMetadata: + """Declarative identity, limits, and permissions for one collector.""" + + id: str + name: str + version: str + specialty: Specialty + description: str + author: str + supported_platforms: tuple[str, ...] = ("win32",) + capabilities: tuple[Capability, ...] = () + sensitive_data_categories: tuple[str, ...] = () + dependencies: tuple[str, ...] = () + default_profiles: tuple[str, ...] = ("standard",) + timeout_seconds: int = 60 + maximum_output_bytes: int = 100 * 1024 * 1024 + minimum_contract_version: str = CONTRACT_VERSION + parallel_safe: bool = True + + def to_dict(self) -> dict[str, Any]: + """Return JSON-safe metadata.""" + data = asdict(self) + data["specialty"] = self.specialty.value + data["capabilities"] = [capability.value for capability in self.capabilities] + return data + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "CollectorMetadata": + """Build metadata returned by an isolated validation worker.""" + values = dict(data) + values["specialty"] = Specialty(values["specialty"]) + values["capabilities"] = tuple(Capability(capability) for capability in values.get("capabilities", ())) + for field_name in ( + "supported_platforms", + "sensitive_data_categories", + "dependencies", + "default_profiles", + ): + values[field_name] = tuple(values.get(field_name, ())) + return cls(**values) + + +@dataclass(frozen=True, slots=True) +class Artifact: + """An evidence artifact registered by a collector.""" + + id: str + relative_path: str + sha256: str + size_bytes: int + media_type: str + collector_id: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class ValidationResult: + """The outcome of a collector's side-effect-free validation phase.""" + + valid: bool + reasons: tuple[str, ...] = () + warnings: tuple[str, ...] = () + + +@dataclass(frozen=True, slots=True) +class CollectorResult: + """The normalized result returned by an isolated collector worker.""" + + status: CollectorStatus + summary: str + artifacts: tuple[Artifact, ...] = () + errors: tuple[str, ...] = () + metrics: Mapping[str, int | float | str] = field(default_factory=dict) + + @classmethod + def succeeded(cls, summary: str, artifacts: tuple[Artifact, ...] = ()) -> "CollectorResult": + return cls(CollectorStatus.SUCCEEDED, summary, artifacts) + + +@dataclass(frozen=True, slots=True) +class RunRequest: + """An immutable user request resolved before any collector starts.""" + + profile: str = "standard" + include: tuple[str, ...] = () + exclude: tuple[str, ...] = () + enable_plugins: bool = False + max_workers: int = 4 + acknowledge_authorization: bool = False + approved_capabilities: tuple[Capability, ...] = () + + +class EventLogger(ABC): + """A structured event sink scoped to one run or collector worker.""" + + @abstractmethod + def event(self, level: str, message: str, **fields: int | float | str) -> None: + """Record one machine-readable event without exposing raw evidence.""" + + +class CollectorContext: + """The limited, per-worker interface exposed to a collector.""" + + def __init__( + self, + run_id: str, + collector_id: str, + workspace: Path, + artifacts: "ArtifactWriter", + logger: EventLogger, + settings: Mapping[str, Any], + cancellation_file: Path, + ) -> None: + self.run_id = run_id + self.collector_id = collector_id + self.workspace = workspace + self.artifacts = artifacts + self.logger = logger + self.settings = settings + self._cancellation_file = cancellation_file + + @property + def is_cancelled(self) -> bool: + """Whether the supervisor has requested cancellation.""" + return self._cancellation_file.exists() + + def report_progress(self, event: str, **metrics: int | float | str) -> None: + """Write a structured progress event local to this collector workspace.""" + self.logger.event("info", event, **metrics) + + +class ArtifactWriter(ABC): + """The only supported route from a collector workspace into run artifacts.""" + + @abstractmethod + def register_file(self, source: Path, *, media_type: str = "application/octet-stream") -> Artifact: + """Register a file created within the collector workspace.""" + + +class Collector(ABC): + """Base interface that all v4 collector classes must implement.""" + + @classmethod + @abstractmethod + def metadata(cls) -> CollectorMetadata: + """Return immutable collector metadata without performing collection.""" + + @abstractmethod + def validate(self, context: CollectorContext) -> ValidationResult: + """Validate prerequisites without creating evidence artifacts.""" + + @abstractmethod + def collect(self, context: CollectorContext) -> CollectorResult: + """Collect evidence and return a normalized result.""" + + def cleanup(self, context: CollectorContext) -> None: + """Release collector-local resources. The engine owns filesystem cleanup.""" + + +class CoreCollector(Collector): + """Marker base class for collectors shipped with Logicytics.""" + + +class PluginCollector(Collector): + """Marker base class for user-owned collectors discovered in plugins/.""" diff --git a/logicytics/discovery.py b/logicytics/discovery.py new file mode 100644 index 00000000..aebe31f8 --- /dev/null +++ b/logicytics/discovery.py @@ -0,0 +1,260 @@ +"""Strict, side-effect-free discovery and preflight for collector modules.""" + +from __future__ import annotations + +import ast +import json +import os +import re +import subprocess +import sys +from dataclasses import dataclass, field +from pathlib import Path + +from logicytics.contracts import CONTRACT_VERSION, CollectorKind, CollectorMetadata + +_FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") +_VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} +_FORBIDDEN_TOP_LEVEL_CALLS = {"exit", "quit", "input", "open", "print"} + + +@dataclass(slots=True) +class CollectorCandidate: + """A file that may be a runnable collector.""" + + path: Path + kind: CollectorKind + expected_class: str + static_errors: list[str] = field(default_factory=list) + metadata: CollectorMetadata | None = None + runtime_error: str | None = None + + @property + def valid(self) -> bool: + """Whether static and runtime validation both passed.""" + return not self.static_errors and self.metadata is not None and self.runtime_error is None + + +@dataclass(frozen=True, slots=True) +class PreflightReport: + """Complete validation outcome for all discovered collector candidates.""" + + candidates: tuple[CollectorCandidate, ...] + + @property + def valid(self) -> tuple[CollectorCandidate, ...]: + return tuple(candidate for candidate in self.candidates if candidate.valid) + + @property + def invalid(self) -> tuple[CollectorCandidate, ...]: + return tuple(candidate for candidate in self.candidates if not candidate.valid) + + +def _pascal_case(filename: str) -> str: + return "".join(part.capitalize() for part in Path(filename).stem.split("_")) + "Collector" + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + except ValueError: + return False + return True + + +def _iter_candidates(project_root: Path, kind: CollectorKind) -> list[Path]: + root = project_root / ("core" if kind is CollectorKind.CORE else "plugins") + if not root.exists(): + return [] + paths: list[Path] = [] + for path in sorted(root.rglob("*.py")): + relative = path.relative_to(root) + if any(part.startswith("_") or part in {"tests", "examples", "venv", ".venv"} for part in relative.parts): + continue + if not _is_within(path, root): + continue + if kind is CollectorKind.CORE and len(relative.parts) != 2: + continue + if kind is CollectorKind.PLUGIN and not (len(relative.parts) == 1 or relative.name == "main.py"): + continue + paths.append(path) + return paths + + +def _top_level_call_name(node: ast.Call) -> str | None: + if isinstance(node.func, ast.Name): + return node.func.id + if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name): + return f"{node.func.value.id}.{node.func.attr}" + return None + + +def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: + expected_class = _pascal_case(path.name) + candidate = CollectorCandidate(path=path, kind=kind, expected_class=expected_class) + if path.name in _VAGUE_NAMES and not (kind is CollectorKind.PLUGIN and path.name == "main.py"): + candidate.static_errors.append("collector filename is reserved or too vague") + if not _FILENAME.fullmatch(path.name): + candidate.static_errors.append("filename must be lowercase snake_case.py") + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (OSError, UnicodeDecodeError, SyntaxError) as error: + candidate.static_errors.append(f"cannot parse UTF-8 Python module: {error}") + return candidate + if ast.get_docstring(tree) is None: + candidate.static_errors.append("module requires a docstring") + + public_classes = [item for item in tree.body if isinstance(item, ast.ClassDef) and not item.name.startswith("_")] + if len(public_classes) != 1: + candidate.static_errors.append("module must define exactly one public collector class") + elif public_classes[0].name != expected_class: + candidate.static_errors.append(f"collector class must be named {expected_class}") + elif not any( + (isinstance(base, ast.Name) and base.id == ("CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + or (isinstance(base, ast.Attribute) and base.attr == ("CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + for base in public_classes[0].bases + ): + candidate.static_errors.append("collector class must inherit from its required base class") + elif public_classes: + _validate_class_shape(public_classes[0], candidate) + + for item in tree.body: + if isinstance(item, ast.Expr) and isinstance(item.value, ast.Constant): + continue + if isinstance(item, (ast.Assign, ast.AnnAssign)): + if any(isinstance(node, ast.Call) for node in ast.walk(item.value)): + candidate.static_errors.append("forbidden call in top-level assignment") + continue + if isinstance(item, (ast.Import, ast.ImportFrom, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): + continue + if isinstance(item, ast.Expr) and isinstance(item.value, ast.Call): + call_name = _top_level_call_name(item.value) + if call_name in _FORBIDDEN_TOP_LEVEL_CALLS or call_name: + candidate.static_errors.append(f"forbidden top-level call: {call_name or 'unknown'}") + continue + candidate.static_errors.append(f"forbidden top-level statement: {type(item).__name__}") + return candidate + + +def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidate) -> None: + """Enforce the small, documented public API allowed on a collector class.""" + if ast.get_docstring(class_node) is None: + candidate.static_errors.append("collector class requires a docstring") + methods = { + item.name: item + for item in class_node.body + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_") + } + allowed = {"metadata", "validate", "collect", "cleanup", "estimate", "dependencies"} + unknown = sorted(set(methods) - allowed) + if unknown: + candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") + required = {"metadata", "validate", "collect", "cleanup"} + missing = sorted(required - set(methods)) + if missing: + candidate.static_errors.append(f"missing required collector methods: {', '.join(missing)}") + for name, method in methods.items(): + if ast.get_docstring(method) is None: + candidate.static_errors.append(f"{name} requires a docstring") + if method.returns is None: + candidate.static_errors.append(f"{name} requires a return type annotation") + parameters = method.args.args + expected_parameter_count = 1 if name == "metadata" else 2 + if len(parameters) != expected_parameter_count: + candidate.static_errors.append(f"{name} has an invalid parameter count") + continue + expected_first = "cls" if name == "metadata" else "self" + if parameters[0].arg != expected_first: + candidate.static_errors.append(f"{name} must begin with {expected_first}") + if name == "metadata": + if not any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in method.decorator_list): + candidate.static_errors.append("metadata must be a classmethod") + else: + context_parameter = parameters[1] + if context_parameter.arg != "context" or context_parameter.annotation is None: + candidate.static_errors.append(f"{name} must accept an annotated context parameter") + + +def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: + """Discover candidates without importing their modules.""" + candidates = [ + _validate_static(path, CollectorKind.CORE) + for path in _iter_candidates(project_root, CollectorKind.CORE) + ] + candidates.extend( + _validate_static(path, CollectorKind.PLUGIN) + for path in _iter_candidates(project_root, CollectorKind.PLUGIN) + ) + return tuple(candidates) + + +def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: + engine_root = Path(__file__).resolve().parent.parent + inherited_pythonpath = os.environ.get("PYTHONPATH", "") + pythonpath = os.pathsep.join( + value for value in (str(engine_root), str(project_root), inherited_pythonpath) if value + ) + command = [ + sys.executable, + "-m", + "logicytics.validation_worker", + str(candidate.path), + candidate.kind.value, + candidate.expected_class, + ] + try: + completed = subprocess.run( + command, + cwd=project_root, + capture_output=True, + text=True, + timeout=10, + check=False, + env={**os.environ, "PYTHONPATH": pythonpath, "LOGICYTICS_VALIDATION": "1"}, + ) + except (OSError, subprocess.TimeoutExpired) as error: + candidate.runtime_error = f"validation worker failed: {error}" + return + if completed.returncode != 0: + candidate.runtime_error = completed.stderr.strip() or "validation worker rejected collector" + return + try: + payload = json.loads(completed.stdout) + metadata = CollectorMetadata.from_dict(payload["metadata"]) + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + candidate.runtime_error = f"invalid validation response: {error}" + return + if metadata.minimum_contract_version != CONTRACT_VERSION: + candidate.runtime_error = "collector contract version is unsupported" + elif metadata.timeout_seconds < 1 or metadata.maximum_output_bytes < 1: + candidate.runtime_error = "collector must declare positive timeout and output limits" + elif not metadata.id.startswith(f"{candidate.kind.value}."): + candidate.runtime_error = "collector ID must start with its owner kind" + elif candidate.kind is CollectorKind.CORE and metadata.specialty.value != candidate.path.parent.name: + candidate.runtime_error = "core collector specialty must match its parent folder" + elif candidate.kind is CollectorKind.CORE and metadata.id != ( + f"core.{candidate.path.parent.name}.{candidate.path.stem}" + ): + candidate.runtime_error = "core collector ID must match core//.py" + elif candidate.kind is CollectorKind.PLUGIN and metadata.id != ( + f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" + ): + candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" + else: + candidate.metadata = metadata + + +def preflight(project_root: Path) -> PreflightReport: + """Perform static checks then a short-lived isolated metadata probe.""" + candidates = list(discover(project_root)) + for candidate in candidates: + if not candidate.static_errors: + _runtime_probe(project_root, candidate) + seen_ids: set[str] = set() + for candidate in candidates: + if candidate.metadata is None: + continue + if candidate.metadata.id in seen_ids: + candidate.runtime_error = f"duplicate collector ID: {candidate.metadata.id}" + seen_ids.add(candidate.metadata.id) + return PreflightReport(tuple(candidates)) diff --git a/logicytics/errors.py b/logicytics/errors.py new file mode 100644 index 00000000..c8d2f00f --- /dev/null +++ b/logicytics/errors.py @@ -0,0 +1,19 @@ +"""Domain exceptions raised by the v4 engine.""" + +from __future__ import annotations + + +class LogicyticsError(Exception): + """Base error for expected application-level failures.""" + + +class PreflightError(LogicyticsError): + """Raised when selected collectors fail strict preflight validation.""" + + +class PlanError(LogicyticsError): + """Raised when a requested run cannot be converted into a valid plan.""" + + +class ArtifactError(LogicyticsError): + """Raised when an artifact violates workspace or output rules.""" diff --git a/logicytics/logging.py b/logicytics/logging.py new file mode 100644 index 00000000..5c21f76c --- /dev/null +++ b/logicytics/logging.py @@ -0,0 +1,34 @@ +"""Run-scoped structured logging without global mutable logging configuration.""" + +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path + +from logicytics.contracts import EventLogger + + +class FileEventLogger(EventLogger): + """Append JSONL events to a single run- or collector-owned log file.""" + + def __init__(self, path: Path, *, run_id: str, collector_id: str | None = None) -> None: + self.path = path + self.run_id = run_id + self.collector_id = collector_id + self.path.parent.mkdir(parents=True, exist_ok=True) + + def event(self, level: str, message: str, **fields: int | float | str) -> None: + """Write a timestamped, structured event without relying on global handlers.""" + payload: dict[str, object] = { + "at": datetime.now(timezone.utc).isoformat(), + "level": level.lower(), + "message": message, + "run_id": self.run_id, + } + if self.collector_id is not None: + payload["collector_id"] = self.collector_id + if fields: + payload["fields"] = fields + with self.path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(payload, sort_keys=True) + "\n") diff --git a/logicytics/manifest.py b/logicytics/manifest.py new file mode 100644 index 00000000..aa643073 --- /dev/null +++ b/logicytics/manifest.py @@ -0,0 +1,122 @@ +"""Atomic run-manifest persistence and normalized execution records.""" + +from __future__ import annotations + +import json +import os +import platform +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping + +from logicytics.contracts import Artifact, CollectorResult, RunStatus + + +def utc_now() -> str: + """Return a stable UTC timestamp for manifests and records.""" + return datetime.now(timezone.utc).isoformat() + + +@dataclass(slots=True) +class CollectorRecord: + """Manifest-visible state for one planned collector.""" + + id: str + source: str + status: str = "planned" + started_at: str | None = None + finished_at: str | None = None + summary: str | None = None + errors: list[str] = field(default_factory=list) + artifacts: list[dict[str, Any]] = field(default_factory=list) + metrics: Mapping[str, int | float | str] = field(default_factory=dict) + duration_seconds: float | None = None + event_count: int = 0 + last_progress_at: str | None = None + + def apply_result(self, result: CollectorResult, duration_seconds: float | None = None) -> None: + """Copy a worker result into this serializable record.""" + self.status = result.status.value + self.summary = result.summary + self.errors = list(result.errors) + self.artifacts = [artifact.to_dict() for artifact in result.artifacts] + self.metrics = dict(result.metrics) + self.duration_seconds = duration_seconds + self.finished_at = utc_now() + + +@dataclass(slots=True) +class RunManifest: + """The durable description of one run, including partial failures.""" + + run_id: str + requested_at: str + status: RunStatus + request: Mapping[str, Any] + configuration: Mapping[str, Any] + collectors: list[CollectorRecord] + host: Mapping[str, str] + finished_at: str | None = None + package: Mapping[str, str] | None = None + total_artifact_bytes: int = 0 + + @classmethod + def create( + cls, + run_id: str, + request: Mapping[str, Any], + configuration: Mapping[str, Any], + collector_sources: list[tuple[str, Path]], + ) -> "RunManifest": + """Create the initial planned manifest before collection begins.""" + return cls( + run_id=run_id, + requested_at=utc_now(), + status=RunStatus.PLANNED, + request=request, + configuration=configuration, + collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], + host={"platform": sys_platform(), "hostname": platform.node(), "python": platform.python_version()}, + ) + + def finalize_status(self) -> None: + """Derive a truthful run status from all collector outcomes.""" + statuses = {record.status for record in self.collectors} + if "cancelled" in statuses: + self.status = RunStatus.CANCELLED + elif "failed" in statuses and len(statuses) == 1: + self.status = RunStatus.FAILED + elif "failed" in statuses or "partial" in statuses or "skipped" in statuses: + self.status = RunStatus.PARTIAL + else: + self.status = RunStatus.SUCCEEDED + self.finished_at = utc_now() + self.total_artifact_bytes = sum(artifact.size_bytes for artifact in self.artifact_list()) + + def artifact_list(self) -> tuple[Artifact, ...]: + """Return all registered artifacts reconstructed from the manifest.""" + return tuple( + Artifact(**artifact) + for record in self.collectors + for artifact in record.artifacts + ) + + def to_dict(self) -> dict[str, Any]: + """Produce JSON-safe manifest data.""" + data = asdict(self) + data["status"] = self.status.value + return data + + +def sys_platform() -> str: + """Expose the runtime platform without importing platform in consumers.""" + return platform.system().lower() + + +def write_manifest(path: Path, manifest: RunManifest) -> None: + """Write a manifest atomically so partial writes never resemble completed runs.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(manifest.to_dict(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + os.replace(temporary, path) diff --git a/logicytics/packaging.py b/logicytics/packaging.py new file mode 100644 index 00000000..79014119 --- /dev/null +++ b/logicytics/packaging.py @@ -0,0 +1,46 @@ +"""Creation of manifest-led evidence packages and their SHA-256 sidecars.""" + +from __future__ import annotations + +import json +import zipfile +from pathlib import Path + +from logicytics.artifacts import sha256_file +from logicytics.manifest import RunManifest, write_manifest +from logicytics.runtime import RunOutcome + + +def _summary(manifest: RunManifest) -> str: + lines = [f"Logicytics run: {manifest.run_id}", f"Status: {manifest.status.value}", "", "Collectors:"] + for record in manifest.collectors: + lines.append(f"- {record.id}: {record.status} — {record.summary or ''}".rstrip()) + lines.extend(["", f"Artifacts: {len(manifest.artifact_list())}"]) + return "\n".join(lines) + "\n" + + +def package_run(outcome: RunOutcome) -> tuple[Path, Path]: + """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" + package_directory = outcome.run_directory.parent.parent / "PACKAGES" + package_directory.mkdir(parents=True, exist_ok=True) + package_path = package_directory / f"{outcome.manifest.run_id}.zip" + hash_path = package_path.with_suffix(".zip.sha256") + outcome.manifest.package = {"path": str(package_path)} + write_manifest(outcome.manifest_path, outcome.manifest) + + summary_path = outcome.run_directory / "summary.txt" + summary_path.write_text(_summary(outcome.manifest), encoding="utf-8") + with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(outcome.manifest_path, "manifest.json") + archive.write(summary_path, "summary.txt") + for artifact in outcome.manifest.artifact_list(): + source = outcome.run_directory / "artifacts" / artifact.relative_path + if not source.is_file(): + raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") + archive.write(source, f"artifacts/{artifact.relative_path}") + for log_path in sorted((outcome.run_directory / "logs").rglob("*.jsonl")): + archive.write(log_path, log_path.relative_to(outcome.run_directory).as_posix()) + for collector_path in sorted((outcome.run_directory / "collectors").rglob("*.jsonl")): + archive.write(collector_path, collector_path.relative_to(outcome.run_directory).as_posix()) + hash_path.write_text(f"{sha256_file(package_path)} {package_path.name}\n", encoding="ascii") + return package_path, hash_path diff --git a/logicytics/planner.py b/logicytics/planner.py new file mode 100644 index 00000000..17669425 --- /dev/null +++ b/logicytics/planner.py @@ -0,0 +1,92 @@ +"""Deterministic conversion of validated collectors into a run plan.""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass + +from logicytics.contracts import CollectorKind, RunRequest +from logicytics.discovery import CollectorCandidate, PreflightReport +from logicytics.errors import PlanError, PreflightError + + +@dataclass(frozen=True, slots=True) +class RunPlan: + """The immutable collector order resolved before execution.""" + + request: RunRequest + collectors: tuple[CollectorCandidate, ...] + + +def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> bool: + assert candidate.metadata is not None + metadata = candidate.metadata + if metadata.id in request.exclude: + return False + if metadata.id in request.include: + return True + if candidate.kind is CollectorKind.PLUGIN and not request.enable_plugins: + return False + return request.profile in metadata.default_profiles + + +def _topological_order(selected: dict[str, CollectorCandidate]) -> tuple[CollectorCandidate, ...]: + ordered: list[CollectorCandidate] = [] + visiting: set[str] = set() + visited: set[str] = set() + + def visit(collector_id: str) -> None: + if collector_id in visited: + return + if collector_id in visiting: + raise PlanError(f"collector dependency cycle contains {collector_id}") + candidate = selected.get(collector_id) + if candidate is None: + raise PlanError(f"selected collector depends on unavailable collector {collector_id}") + assert candidate.metadata is not None + visiting.add(collector_id) + for dependency in sorted(candidate.metadata.dependencies): + visit(dependency) + visiting.remove(collector_id) + visited.add(collector_id) + ordered.append(candidate) + + for collector_id in sorted(selected): + visit(collector_id) + return tuple(ordered) + + +def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: + """Fail closed for invalid selected work and resolve a dependency-safe plan.""" + if request.max_workers < 1: + raise PlanError("max_workers must be positive") + valid = {candidate.metadata.id: candidate for candidate in report.valid if candidate.metadata is not None} + requested_names = {collector_id.rsplit(".", 1)[-1] for collector_id in request.include} + invalid_selected = [ + candidate + for candidate in report.invalid + if candidate.kind is CollectorKind.CORE or candidate.path.stem in requested_names + ] + if invalid_selected: + details = "; ".join( + f"{candidate.path}: {', '.join(candidate.static_errors) or candidate.runtime_error or 'invalid'}" + for candidate in invalid_selected + ) + raise PreflightError(f"collector preflight failed: {details}") + unknown_includes = sorted(set(request.include) - set(valid)) + if unknown_includes: + raise PlanError(f"requested collectors are unavailable: {', '.join(unknown_includes)}") + selected = { + collector_id: candidate + for collector_id, candidate in valid.items() + if _selected_by_request(candidate, request) + } + for candidate in selected.values(): + assert candidate.metadata is not None + if sys.platform not in candidate.metadata.supported_platforms: + raise PlanError(f"{candidate.metadata.id} does not support {sys.platform}") + missing_capabilities = set(candidate.metadata.capabilities) - set(request.approved_capabilities) + if missing_capabilities: + required = ", ".join(sorted(capability.value for capability in missing_capabilities)) + raise PlanError(f"{candidate.metadata.id} requires unapproved capabilities: {required}") + return RunPlan(request=request, collectors=_topological_order(selected)) diff --git a/logicytics/runtime.py b/logicytics/runtime.py new file mode 100644 index 00000000..7fe9131f --- /dev/null +++ b/logicytics/runtime.py @@ -0,0 +1,352 @@ +"""Per-collector process supervisor and run workspace lifecycle.""" + +from __future__ import annotations + +import contextlib +import importlib.util +import multiprocessing +import os +import queue +import subprocess +import traceback +from dataclasses import asdict, dataclass +from pathlib import Path +from time import monotonic, sleep +from uuid import uuid4 + +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.configuration import AppConfig +from logicytics.contracts import ( + Artifact, + CollectorContext, + CollectorResult, + CollectorStatus, + RunRequest, + RunStatus, + ValidationResult, +) +from logicytics.manifest import RunManifest, write_manifest, utc_now +from logicytics.logging import FileEventLogger +from logicytics.planner import RunPlan + + +@dataclass(slots=True) +class RunOutcome: + """The completed run and the durable locations that describe it.""" + + manifest: RunManifest + run_directory: Path + manifest_path: Path + + +@dataclass(slots=True) +class _ActiveWorker: + candidate_id: str + process: multiprocessing.Process + started_at: float + timeout_seconds: int + workspace: Path + + +def _load_collector(path: Path, expected_class: str): + module_name = f"logicytics_runtime_{uuid4().hex}" + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ValueError("unable to load collector module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, expected_class)() + + +def _artifact_from_dict(data: dict[str, object]) -> Artifact: + return Artifact( + id=str(data["id"]), + relative_path=str(data["relative_path"]), + sha256=str(data["sha256"]), + size_bytes=int(data["size_bytes"]), + media_type=str(data["media_type"]), + collector_id=str(data["collector_id"]), + ) + + +def _result_from_dict(data: dict[str, object]) -> CollectorResult: + return CollectorResult( + status=CollectorStatus(str(data["status"])), + summary=str(data["summary"]), + artifacts=tuple(_artifact_from_dict(item) for item in data.get("artifacts", [])), + errors=tuple(str(error) for error in data.get("errors", [])), + metrics=dict(data.get("metrics", {})), + ) + + +def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queue) -> None: + """Run a single collector in an isolated child process.""" + workspace = Path(str(payload["workspace"])) + workspace.mkdir(parents=True, exist_ok=True) + stdout_path = workspace / "stdout.log" + stderr_path = workspace / "stderr.log" + try: + with stdout_path.open("w", encoding="utf-8") as stdout, stderr_path.open("w", encoding="utf-8") as stderr: + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + collector = _load_collector(Path(str(payload["path"])), str(payload["expected_class"])) + metadata = collector.metadata() + writer = WorkspaceArtifactWriter( + metadata.id, + workspace, + Path(str(payload["artifact_root"])), + metadata.maximum_output_bytes, + ) + context = CollectorContext( + run_id=str(payload["run_id"]), + collector_id=metadata.id, + workspace=workspace, + artifacts=writer, + logger=FileEventLogger( + workspace / "events.jsonl", + run_id=str(payload["run_id"]), + collector_id=metadata.id, + ), + settings=dict(payload["settings"]), + cancellation_file=Path(str(payload["cancellation_file"])), + ) + validation = collector.validate(context) + if not isinstance(validation, ValidationResult): + raise TypeError("validate() must return ValidationResult") + if not validation.valid: + result = CollectorResult( + status=CollectorStatus.SKIPPED, + summary="collector prerequisites were not met", + errors=validation.reasons, + ) + elif context.is_cancelled: + result = CollectorResult(CollectorStatus.CANCELLED, "cancelled before collection") + else: + context.logger.event("info", "collection_started") + result = collector.collect(context) + if not isinstance(result, CollectorResult): + raise TypeError("collect() must return CollectorResult") + if result.artifacts != writer.artifacts: + raise TypeError("collector result artifacts must exactly match registered artifacts") + collector.cleanup(context) + context.logger.event("info", "collection_finished", status=result.status.value) + result_queue.put({"collector_id": metadata.id, "result": _serialize_result(result)}) + except BaseException as error: # child processes must always report a terminal result + result_queue.put( + { + "collector_id": str(payload["collector_id"]), + "result": _serialize_result( + CollectorResult( + CollectorStatus.FAILED, + "collector worker crashed", + errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), + ) + ), + } + ) + + +def _serialize_result(result: CollectorResult) -> dict[str, object]: + return { + "status": result.status.value, + "summary": result.summary, + "artifacts": [artifact.to_dict() for artifact in result.artifacts], + "errors": list(result.errors), + "metrics": dict(result.metrics), + } + + +class RunSupervisor: + """Plans process isolation, timeouts, manifests, and collector failure containment.""" + + def __init__(self, project_root: Path, configuration: AppConfig) -> None: + self.project_root = project_root.resolve() + self.configuration = configuration + self._active_workers: dict[str, _ActiveWorker] = {} + + def run(self, plan: RunPlan) -> RunOutcome: + """Execute a preflighted plan and persist the manifest throughout the run.""" + if plan.collectors and not plan.request.acknowledge_authorization: + raise PermissionError("collection requires acknowledge_authorization=True") + if plan.request.max_workers > self.configuration.runtime.maximum_workers: + raise ValueError("requested workers exceed configured maximum_workers") + run_id = f"run-{uuid4().hex}" + run_directory = self.configuration.runtime.output_root / run_id + workspace_root = run_directory / "collectors" + artifact_root = run_directory / "artifacts" + cancellation_file = run_directory / ".cancelled" + workspace_root.mkdir(parents=True, exist_ok=False) + artifact_root.mkdir(parents=True, exist_ok=False) + run_logger = FileEventLogger(run_directory / "logs" / "engine.jsonl", run_id=run_id) + manifest_path = run_directory / "manifest.json" + manifest = RunManifest.create( + run_id, + asdict(plan.request), + self.configuration.to_manifest_dict(), + [(candidate.metadata.id, candidate.path) for candidate in plan.collectors if candidate.metadata], + ) + manifest.status = RunStatus.RUNNING + write_manifest(manifest_path, manifest) + run_logger.event("info", "run_started", collectors=len(plan.collectors)) + + records = {record.id: record for record in manifest.collectors} + try: + self._supervise( + plan, + run_id, + workspace_root, + artifact_root, + cancellation_file, + manifest, + manifest_path, + records, + run_logger, + ) + except KeyboardInterrupt: + cancellation_file.touch() + run_logger.event("warning", "run_cancellation_requested") + self._cancel_records(records, manifest, manifest_path, "run cancelled by user") + + manifest.finalize_status() + write_manifest(manifest_path, manifest) + run_logger.event("info", "run_finished", status=manifest.status.value, artifacts=manifest.total_artifact_bytes) + return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) + + def _supervise( + self, + plan: RunPlan, + run_id: str, + workspace_root: Path, + artifact_root: Path, + cancellation_file: Path, + manifest: RunManifest, + manifest_path: Path, + records: dict[str, object], + run_logger: FileEventLogger, + ) -> None: + """Schedule bounded isolated workers and contain each terminal failure.""" + pending = list(plan.collectors) + active: dict[str, _ActiveWorker] = {} + self._active_workers = active + result_queue: multiprocessing.Queue = multiprocessing.get_context("spawn").Queue() + worker_limit = min(plan.request.max_workers, self.configuration.runtime.maximum_workers) + while pending or active: + if cancellation_file.exists(): + self._cancel_active(active, records, manifest, manifest_path, "run cancellation requested") + for candidate in pending: + assert candidate.metadata is not None + records[candidate.metadata.id].apply_result( + CollectorResult(CollectorStatus.CANCELLED, "not started because run was cancelled") + ) + return + while pending and len(active) < worker_limit: + candidate = pending.pop(0) + assert candidate.metadata is not None + workspace = workspace_root / candidate.metadata.id.replace(".", "_") + payload: dict[str, object] = { + "run_id": run_id, + "collector_id": candidate.metadata.id, + "path": str(candidate.path), + "expected_class": candidate.expected_class, + "workspace": str(workspace), + "artifact_root": str(artifact_root), + "cancellation_file": str(cancellation_file), + "settings": dict(self.configuration.settings_for(candidate.metadata.id)), + } + process = multiprocessing.get_context("spawn").Process( + target=_worker_entry, + args=(payload, result_queue), + name=f"Logicytics-{candidate.metadata.id}", + ) + records[candidate.metadata.id].status = "running" + records[candidate.metadata.id].started_at = utc_now() + process.start() + active[candidate.metadata.id] = _ActiveWorker( + candidate.metadata.id, + process, + monotonic(), + candidate.metadata.timeout_seconds, + workspace, + ) + run_logger.event("info", "collector_started", collector_id=candidate.metadata.id) + write_manifest(manifest_path, manifest) + + try: + message = result_queue.get(timeout=0.1) + except queue.Empty: + message = None + if message is not None: + collector_id = str(message["collector_id"]) + worker = active.pop(collector_id, None) + if worker is not None: + worker.process.join(timeout=1) + self._apply_worker_result(records[collector_id], _result_from_dict(message["result"]), worker) + run_logger.event("info", "collector_finished", collector_id=collector_id) + write_manifest(manifest_path, manifest) + + for collector_id, worker in tuple(active.items()): + if monotonic() - worker.started_at > worker.timeout_seconds: + self._terminate_process_tree(worker.process) + active.pop(collector_id) + self._apply_worker_result( + records[collector_id], + CollectorResult(CollectorStatus.FAILED, "collector exceeded its declared timeout"), + worker, + ) + run_logger.event("error", "collector_timed_out", collector_id=collector_id) + write_manifest(manifest_path, manifest) + elif not worker.process.is_alive() and worker.process.exitcode not in (None, 0): + worker.process.join(timeout=1) + active.pop(collector_id) + self._apply_worker_result( + records[collector_id], + CollectorResult(CollectorStatus.FAILED, "collector exited without a result"), + worker, + ) + run_logger.event("error", "collector_exited_without_result", collector_id=collector_id) + write_manifest(manifest_path, manifest) + sleep(0.01) + + @staticmethod + def _terminate_process_tree(process: multiprocessing.Process) -> None: + """Terminate a worker and, on Windows, any subprocesses it created.""" + if process.pid is not None and os.name == "nt": + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + capture_output=True, + check=False, + text=True, + ) + if process.is_alive(): + process.terminate() + process.join(timeout=2) + + @staticmethod + def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) -> None: + """Attach result, elapsed time, and progress accounting to one record.""" + record.apply_result(result, duration_seconds=round(monotonic() - worker.started_at, 3)) + events_path = worker.workspace / "events.jsonl" + if events_path.exists(): + events = events_path.read_text(encoding="utf-8").splitlines() + record.event_count = len(events) + if events: + import json + + record.last_progress_at = json.loads(events[-1]).get("at") + + def _cancel_active(self, active, records, manifest, manifest_path, reason: str) -> None: + for collector_id, worker in tuple(active.items()): + self._terminate_process_tree(worker.process) + active.pop(collector_id) + self._apply_worker_result( + records[collector_id], + CollectorResult(CollectorStatus.CANCELLED, reason), + worker, + ) + write_manifest(manifest_path, manifest) + + def _cancel_records(self, records, manifest, manifest_path, reason: str) -> None: + self._cancel_active(self._active_workers, records, manifest, manifest_path, reason) + for record in records.values(): + if record.status in {"planned", "running"}: + record.apply_result(CollectorResult(CollectorStatus.CANCELLED, reason)) + write_manifest(manifest_path, manifest) diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py new file mode 100644 index 00000000..e0aecb8e --- /dev/null +++ b/logicytics/validation_worker.py @@ -0,0 +1,70 @@ +"""Short-lived metadata probe used only after AST-based collector validation.""" + +from __future__ import annotations + +import importlib.util +import inspect +import json +import sys +from pathlib import Path + +from logicytics.contracts import Collector, CollectorKind, CollectorMetadata, CoreCollector, PluginCollector + + +def _load_module(path: Path): + module_name = f"logicytics_probe_{path.stem}" + spec = importlib.util.spec_from_file_location(module_name, path) + if spec is None or spec.loader is None: + raise ValueError("unable to create module specification") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> CollectorMetadata: + required_base = CoreCollector if kind is CollectorKind.CORE else PluginCollector + if not issubclass(collector_type, required_base): + raise ValueError(f"collector must inherit from {required_base.__name__}") + for method_name, expected_parameters in { + "metadata": 0, + "validate": 1, + "collect": 1, + "cleanup": 1, + }.items(): + method = getattr(collector_type, method_name, None) + if method is None: + raise ValueError(f"missing required method: {method_name}") + parameters = list(inspect.signature(method).parameters.values()) + if method_name == "metadata": + parameters = parameters[1:] if parameters and parameters[0].name == "cls" else parameters + elif parameters and parameters[0].name == "self": + parameters = parameters[1:] + if len(parameters) != expected_parameters: + raise ValueError(f"{method_name} has an invalid signature") + metadata = collector_type.metadata() + if not isinstance(metadata, CollectorMetadata): + raise ValueError("metadata() must return CollectorMetadata") + return metadata + + +def main() -> int: + """Validate a collector module and emit only JSON to stdout.""" + try: + path = Path(sys.argv[1]).resolve() + kind = CollectorKind(sys.argv[2]) + expected_class = sys.argv[3] + sys.path.insert(0, str(path.parent)) + module = _load_module(path) + collector_type = getattr(module, expected_class, None) + if not inspect.isclass(collector_type): + raise ValueError(f"missing collector class: {expected_class}") + metadata = _validate_contract(collector_type, kind) + print(json.dumps({"metadata": metadata.to_dict()}, sort_keys=True)) + return 0 + except (IndexError, TypeError, ValueError, ImportError, OSError) as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..45c7eb13 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "logicytics" +version = "4.0.0a1" +description = "A run-oriented Windows evidence collection framework." +requires-python = ">=3.11" +dependencies = [] + +[project.scripts] +logicytics = "logicytics.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/requirements.txt b/requirements.txt index cd03dd15..4843a0aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,2 @@ -configobj~=5.0.9 -configparser~=7.2.0 -psutil~=7.2.2 -requests~=2.34.2 -DateTime~=6.0 -colorlog~=6.12.0 -aiofiles~=25.1.0 -joblib~=1.5.3 -numpy~=2.5.2 -pathlib~=1.0.1 -tqdm~=4.70.0 -safetensors~=0.8.0 -WMI~=1.5.1 -prettytable~=3.18.0 -matplotlib~=3.11.1 -networkx~=3.6.1 -pandas~=3.0.5 -cryptography~=50.0.0 -scapy~=2.7.0 -sentence-transformers~=5.7.0 \ No newline at end of file +# Logicytics v4 core uses only the Python standard library. +# Collector-specific dependencies belong to the collector that needs them. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..f720d0fe --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Logicytics v4 test suite.""" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py new file mode 100644 index 00000000..2fd24371 --- /dev/null +++ b/tests/test_core_functionality.py @@ -0,0 +1,150 @@ +"""Integration-style checks for the v4 core without shipped collectors.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.configuration import default_config +from logicytics.contracts import Capability, RunRequest +from logicytics.discovery import preflight +from logicytics.errors import ArtifactError, PlanError, PreflightError +from logicytics.packaging import package_run +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +_COLLECTOR = '''"""Create a harmless test artifact.""" + +from pathlib import Path + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext + + +class SystemInfoCollector(CoreCollector): + """A valid test core collector.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Return test metadata.""" + return CollectorMetadata( + id="core.system.system_info", + name="System info", + version="1.0.0", + specialty=Specialty.SYSTEM, + description="Creates a harmless text artifact for core tests.", + author="tests", + supported_platforms=("win32",), + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Validate the test collector.""" + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Write and register a harmless artifact.""" + output = context.workspace / "system.txt" + output.write_text("ok\\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + return CollectorResult.succeeded("test artifact created", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release test resources.""" +''' + + +class CoreFunctionalityTests(unittest.TestCase): + """Validate the foundation before real core collectors are added.""" + + def test_artifacts_cannot_escape_collector_workspace(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + outside = root / "outside.txt" + outside.write_text("no", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024) + with self.assertRaises(ArtifactError): + writer.register_file(outside) + + def test_preflight_plan_run_and_package(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + plan = build_plan( + report, + RunRequest(profile="standard", max_workers=1, acknowledge_authorization=True), + ) + configuration = default_config(root) + outcome = RunSupervisor(root, configuration).run(plan) + self.assertEqual("succeeded", outcome.manifest.status.value) + self.assertEqual(1, len(outcome.manifest.artifact_list())) + package_path, hash_path = package_run(outcome) + self.assertTrue(package_path.is_file()) + self.assertTrue(hash_path.is_file()) + + def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text('"""Invalid core collector."""\n', encoding="utf-8") + plugin_path = root / "plugins" / "broken_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + report = preflight(root) + self.assertEqual(2, len(report.invalid)) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest()) + + def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text(_COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "broken_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + report = preflight(root) + plan = build_plan(report, RunRequest()) + self.assertEqual( + ["core.system.system_info"], + [candidate.metadata.id for candidate in plan.collectors], + ) + + def test_capability_gate_requires_explicit_approval(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + secured_collector = _COLLECTOR.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + 'supported_platforms=("win32",),', + 'supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,),', + ) + collector_path.write_text(secured_collector, encoding="utf-8") + report = preflight(root) + with self.assertRaises(PlanError): + build_plan(report, RunRequest()) + plan = build_plan( + report, + RunRequest(approved_capabilities=(Capability.FILESYSTEM_READ,)), + ) + self.assertEqual(1, len(plan.collectors)) + + +if __name__ == "__main__": + unittest.main() From 96f0738732f3c8fdfc247097343bebee93da2a98 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:33:31 +0300 Subject: [PATCH 003/316] feat: add capability-gated process collector --- README.md | 3 +- core/process/running_processes.py | 83 +++++++++++++++++++++++ tests/test_running_processes_collector.py | 66 ++++++++++++++++++ tests/test_shipped_collectors.py | 26 +++++++ 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 core/process/running_processes.py create mode 100644 tests/test_running_processes_collector.py create mode 100644 tests/test_shipped_collectors.py diff --git a/README.md b/README.md index 3ad919c0..30126ba0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,8 @@ rebuilt incrementally. - Structured JSONL engine and collector logs. - Artifact registration with workspace boundaries, output limits, SHA-256 hashes, a run manifest, ZIP package, and package hash. -- First shipped collector: `core.system.system_info`. +- Shipped collectors: `core.system.system_info` and the capability-gated + `core.process.running_processes`. ## Layout diff --git a/core/process/running_processes.py b/core/process/running_processes.py new file mode 100644 index 00000000..2a3104ec --- /dev/null +++ b/core/process/running_processes.py @@ -0,0 +1,83 @@ +"""Collect a bounded CSV inventory of running Windows processes.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +class RunningProcessesCollector(CoreCollector): + """Capture the non-verbose Windows Tasklist report through the artifact boundary.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the local subprocess capability and bounded CSV output contract.""" + return CollectorMetadata( + id="core.process.running_processes", + name="Running processes", + version="4.0.0", + specialty=Specialty.PROCESS, + description="Exports the non-verbose Windows Tasklist process inventory as CSV.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + default_profiles=("standard", "deep"), + timeout_seconds=20, + maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Verify that the Windows Tasklist command is available before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("tasklist") is None: + return ValidationResult(False, reasons=("tasklist is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run Tasklist, write its CSV output in the private workspace, and register it.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Tasklist execution") + context.report_progress("tasklist_started") + completed = subprocess.run( + ["tasklist", "/FO", "CSV", "/NH"], + capture_output=True, + check=False, + text=True, + timeout=15, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"tasklist exit code {completed.returncode}" + if "access denied" in detail.casefold() or "access is denied" in detail.casefold(): + return CollectorResult( + CollectorStatus.SKIPPED, + "Tasklist access was denied for the current account", + errors=(detail,), + ) + return CollectorResult( + CollectorStatus.FAILED, + "Tasklist did not complete successfully", + errors=(detail,), + ) + output = context.workspace / "running_processes.csv" + output.write_text(completed.stdout, encoding="utf-8", newline="") + artifact = context.artifacts.register_file(output, media_type="text/csv") + process_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("tasklist_finished", processes=process_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded( + "running-process inventory collected", + (artifact,), + ) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because Tasklist completes before the result is returned.""" diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py new file mode 100644 index 00000000..a0f411e0 --- /dev/null +++ b/tests/test_running_processes_collector.py @@ -0,0 +1,66 @@ +"""Behavior checks for the capability-gated process collector.""" + +from __future__ import annotations + +import importlib.util +import tempfile +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import patch + +from logicytics.contracts import ArtifactWriter, CollectorContext, CollectorStatus, EventLogger + + +class _UnusedArtifactWriter(ArtifactWriter): + """Raise if a skipped collector unexpectedly attempts artifact registration.""" + + def register_file(self, source: Path, *, media_type: str = "application/octet-stream"): + raise AssertionError("a skipped collector must not register artifacts") + + +class _NoopLogger(EventLogger): + """Discard events while directly exercising a collector method.""" + + def event(self, level: str, message: str, **fields: int | float | str) -> None: + return None + + +def _collector_type(): + path = Path(__file__).resolve().parent.parent / "core" / "process" / "running_processes.py" + spec = importlib.util.spec_from_file_location("running_processes_test", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module, module.RunningProcessesCollector + + +class RunningProcessesCollectorTests(unittest.TestCase): + """Ensure expected local permission restrictions are reported as a skip.""" + + def test_access_denied_is_skipped_not_failed(self) -> None: + """Tasklist permission denials should preserve the rest of the run.""" + module, collector_type = _collector_type() + with tempfile.TemporaryDirectory() as temporary: + workspace = Path(temporary) + context = CollectorContext( + run_id="test-run", + collector_id="core.process.running_processes", + workspace=workspace, + artifacts=_UnusedArtifactWriter(), + logger=_NoopLogger(), + settings={}, + cancellation_file=workspace / ".cancelled", + ) + with patch.object( + module.subprocess, + "run", + return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), + ): + result = collector_type().collect(context) + self.assertEqual(CollectorStatus.SKIPPED, result.status) + self.assertIn("denied", result.summary.casefold()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py new file mode 100644 index 00000000..3f743fe0 --- /dev/null +++ b/tests/test_shipped_collectors.py @@ -0,0 +1,26 @@ +"""Contract-level checks for collectors shipped in the repository tree.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from logicytics.discovery import preflight + + +class ShippedCollectorTests(unittest.TestCase): + """Ensure every checked-in core collector passes strict discovery rules.""" + + def test_shipped_collectors_pass_preflight(self) -> None: + """The repository must never contain an invalid shipped collector.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + self.assertEqual((), report.invalid) + self.assertEqual( + {"core.system.system_info", "core.process.running_processes"}, + {candidate.metadata.id for candidate in report.valid if candidate.metadata}, + ) + + +if __name__ == "__main__": + unittest.main() From 9e9090bdde481eab3506dea57a801a3e1ecf1431 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:34:39 +0300 Subject: [PATCH 004/316] feat: add network identity collector --- README.md | 2 +- core/network/network_identity.py | 86 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 6 ++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 core/network/network_identity.py diff --git a/README.md b/README.md index 30126ba0..1a3ee4da 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ rebuilt incrementally. - Artifact registration with workspace boundaries, output limits, SHA-256 hashes, a run manifest, ZIP package, and package hash. - Shipped collectors: `core.system.system_info` and the capability-gated - `core.process.running_processes`. + `core.process.running_processes` and `core.network.network_identity`. ## Layout diff --git a/core/network/network_identity.py b/core/network/network_identity.py new file mode 100644 index 00000000..e33559e9 --- /dev/null +++ b/core/network/network_identity.py @@ -0,0 +1,86 @@ +"""Collect the hostname and resolver-provided addresses for the local host.""" + +from __future__ import annotations + +import json +import socket +from datetime import datetime, timezone + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +class NetworkIdentityCollector(CoreCollector): + """Capture a bounded local network identity report without probing remote hosts.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare resolver access and the small JSON output produced by this collector.""" + return CollectorMetadata( + id="core.network.network_identity", + name="Network identity", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Records the local hostname and resolver-provided IP addresses without probing hosts.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.NETWORK,), + default_profiles=("standard", "deep"), + timeout_seconds=10, + maximum_output_bytes=64 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Confirm that the run has not been cancelled before resolving local identity.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Resolve the local hostname and write a deterministic JSON report.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before network identity collection") + context.report_progress("network_identity_started") + hostname = socket.gethostname() + addresses: list[str] = [] + resolver_error: str | None = None + try: + for result in socket.getaddrinfo(hostname, None): + address = result[4][0] + if address not in addresses: + addresses.append(address) + except socket.gaierror as error: + resolver_error = str(error) + report = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "hostname": hostname, + "addresses": sorted(addresses), + } + if resolver_error is not None: + report["resolver_error"] = resolver_error + output = context.workspace / "network_identity.json" + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress( + "network_identity_finished", + address_count=len(addresses), + bytes_written=artifact.size_bytes, + ) + if resolver_error is not None: + return CollectorResult( + CollectorStatus.PARTIAL, + "network identity collected without resolver addresses", + (artifact,), + errors=(resolver_error,), + ) + return CollectorResult.succeeded("network identity collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because hostname resolution has already completed.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 3f743fe0..bc1f096c 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -17,7 +17,11 @@ def test_shipped_collectors_pass_preflight(self) -> None: report = preflight(project_root) self.assertEqual((), report.invalid) self.assertEqual( - {"core.system.system_info", "core.process.running_processes"}, + { + "core.network.network_identity", + "core.process.running_processes", + "core.system.system_info", + }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) From 7f6c0bc8f5e5e5528580b8dd1f2ab0cad56e2481 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:36:00 +0300 Subject: [PATCH 005/316] feat: add Windows memory snapshot collector --- README.md | 3 +- core/memory/memory_snapshot.py | 99 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 core/memory/memory_snapshot.py diff --git a/README.md b/README.md index 1a3ee4da..ebf57507 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ rebuilt incrementally. - Artifact registration with workspace boundaries, output limits, SHA-256 hashes, a run manifest, ZIP package, and package hash. - Shipped collectors: `core.system.system_info` and the capability-gated - `core.process.running_processes` and `core.network.network_identity`. + `core.process.running_processes` and `core.network.network_identity`, plus + `core.memory.memory_snapshot`. ## Layout diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py new file mode 100644 index 00000000..5eca1970 --- /dev/null +++ b/core/memory/memory_snapshot.py @@ -0,0 +1,99 @@ +"""Collect bounded aggregate physical and virtual memory statistics from Windows.""" + +from __future__ import annotations + +import ctypes +import json +from datetime import datetime, timezone + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +class _MemoryStatus(ctypes.Structure): + """Mirror the Windows MEMORYSTATUSEX structure used by GlobalMemoryStatusEx.""" + + _fields_ = [ + ("dwLength", ctypes.c_ulong), + ("dwMemoryLoad", ctypes.c_ulong), + ("ullTotalPhys", ctypes.c_ulonglong), + ("ullAvailPhys", ctypes.c_ulonglong), + ("ullTotalPageFile", ctypes.c_ulonglong), + ("ullAvailPageFile", ctypes.c_ulonglong), + ("ullTotalVirtual", ctypes.c_ulonglong), + ("ullAvailVirtual", ctypes.c_ulonglong), + ("ullAvailExtendedVirtual", ctypes.c_ulonglong), + ] + + +def _read_memory_status() -> _MemoryStatus: + """Read Windows aggregate memory counters or raise the platform error.""" + status = _MemoryStatus() + status.dwLength = ctypes.sizeof(_MemoryStatus) + if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): + raise ctypes.WinError() + return status + + +class MemorySnapshotCollector(CoreCollector): + """Write a JSON report containing aggregate memory and page-file availability.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare this bounded, capability-free Windows memory collector.""" + return CollectorMetadata( + id="core.memory.memory_snapshot", + name="Memory snapshot", + version="4.0.0", + specialty=Specialty.MEMORY, + description="Captures aggregate physical, virtual, and page-file memory statistics.", + author="Logicytics", + supported_platforms=("win32",), + default_profiles=("minimal", "standard", "deep", "offline"), + timeout_seconds=10, + maximum_output_bytes=64 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation and Windows memory API availability without writing artifacts.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + _read_memory_status() + except OSError as error: + return ValidationResult(False, reasons=(f"memory API is unavailable: {error}",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Capture aggregate counters and register the resulting JSON report.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory collection") + context.report_progress("memory_snapshot_started") + try: + status = _read_memory_status() + except OSError as error: + return CollectorResult(CollectorStatus.FAILED, "could not read Windows memory status", errors=(str(error),)) + report = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "memory_load_percent": status.dwMemoryLoad, + "physical_memory": { + "total_bytes": status.ullTotalPhys, + "available_bytes": status.ullAvailPhys, + }, + "page_file": { + "total_bytes": status.ullTotalPageFile, + "available_bytes": status.ullAvailPageFile, + }, + "virtual_memory": { + "total_bytes": status.ullTotalVirtual, + "available_bytes": status.ullAvailVirtual, + }, + } + output = context.workspace / "memory_snapshot.json" + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("memory_snapshot_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("memory snapshot collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the Windows memory API is synchronous.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index bc1f096c..398d8005 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -19,6 +19,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: self.assertEqual( { "core.network.network_identity", + "core.memory.memory_snapshot", "core.process.running_processes", "core.system.system_info", }, From 860a8089ce82958ab7579f600ccfd5ea90fce620 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:37:23 +0300 Subject: [PATCH 006/316] feat: add logical drive metadata collector --- README.md | 2 +- core/storage/logical_drives.py | 105 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 core/storage/logical_drives.py diff --git a/README.md b/README.md index ebf57507..bd7b9c2d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ rebuilt incrementally. a run manifest, ZIP package, and package hash. - Shipped collectors: `core.system.system_info` and the capability-gated `core.process.running_processes` and `core.network.network_identity`, plus - `core.memory.memory_snapshot`. + `core.memory.memory_snapshot` and `core.storage.logical_drives`. ## Layout diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py new file mode 100644 index 00000000..47ef6212 --- /dev/null +++ b/core/storage/logical_drives.py @@ -0,0 +1,105 @@ +"""Collect bounded Windows logical-drive capacity and type metadata.""" + +from __future__ import annotations + +import ctypes +import json +from datetime import datetime, timezone + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_DRIVE_TYPES = { + 0: "unknown", + 1: "no_root_directory", + 2: "removable", + 3: "fixed", + 4: "remote", + 5: "optical", + 6: "ram_disk", +} + + +def _logical_drives() -> list[dict[str, int | str]]: + """Return Windows logical drive metadata without walking any filesystem contents.""" + mask = ctypes.windll.kernel32.GetLogicalDrives() + if mask == 0: + raise ctypes.WinError() + drives: list[dict[str, int | str]] = [] + for offset in range(26): + if not mask & (1 << offset): + continue + root = f"{chr(ord('A') + offset)}:\\" + available = ctypes.c_ulonglong() + total = ctypes.c_ulonglong() + free = ctypes.c_ulonglong() + if not ctypes.windll.kernel32.GetDiskFreeSpaceExW( + root, + ctypes.byref(available), + ctypes.byref(total), + ctypes.byref(free), + ): + continue + drive_type_code = ctypes.windll.kernel32.GetDriveTypeW(root) + drives.append( + { + "root": root, + "type": _DRIVE_TYPES.get(drive_type_code, "unknown"), + "total_bytes": total.value, + "free_bytes": free.value, + "available_bytes": available.value, + } + ) + return drives + + +class LogicalDrivesCollector(CoreCollector): + """Write a JSON inventory of mounted logical-drive capacity and type metadata.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare a bounded, capability-free storage metadata collector.""" + return CollectorMetadata( + id="core.storage.logical_drives", + name="Logical drives", + version="4.0.0", + specialty=Specialty.STORAGE, + description="Records mounted logical-drive types and aggregate capacity metadata.", + author="Logicytics", + supported_platforms=("win32",), + default_profiles=("minimal", "standard", "deep", "offline"), + timeout_seconds=10, + maximum_output_bytes=64 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation and ensure the Windows logical-drive API is available.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + _logical_drives() + except OSError as error: + return ValidationResult(False, reasons=(f"logical-drive API is unavailable: {error}",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Capture logical-drive metadata and register a bounded JSON report.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before storage collection") + context.report_progress("logical_drives_started") + try: + drives = _logical_drives() + except OSError as error: + return CollectorResult(CollectorStatus.FAILED, "could not read logical drives", errors=(str(error),)) + report = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "drives": drives, + } + output = context.workspace / "logical_drives.json" + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("logical_drives_finished", drive_count=len(drives), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("logical-drive metadata collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the Windows storage API is synchronous.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 398d8005..fa94ed4e 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -21,6 +21,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.network_identity", "core.memory.memory_snapshot", "core.process.running_processes", + "core.storage.logical_drives", "core.system.system_info", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, From 1d04e9d8c785b9da688acc2cbf0498e560ed31d3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:40:08 +0300 Subject: [PATCH 007/316] feat: add Windows optional features collector --- README.md | 3 +- core/hardware/windows_features.py | 99 ++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + tests/test_windows_features_collector.py | 31 ++++++++ 4 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 core/hardware/windows_features.py create mode 100644 tests/test_windows_features_collector.py diff --git a/README.md b/README.md index bd7b9c2d..532e1ae7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ rebuilt incrementally. a run manifest, ZIP package, and package hash. - Shipped collectors: `core.system.system_info` and the capability-gated `core.process.running_processes` and `core.network.network_identity`, plus - `core.memory.memory_snapshot` and `core.storage.logical_drives`. + `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile + `core.hardware.windows_features`. ## Layout diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py new file mode 100644 index 00000000..a7bd32b2 --- /dev/null +++ b/core/hardware/windows_features.py @@ -0,0 +1,99 @@ +"""Collect Windows optional-feature names and states through a bounded PowerShell query.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common Windows and PowerShell permission-denied error wording.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class WindowsFeaturesCollector(CoreCollector): + """Export Windows optional-feature state as a JSON artifact in the collector workspace.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated deep inventory and its bounded output limit.""" + return CollectorMetadata( + id="core.hardware.windows_features", + name="Windows optional features", + version="4.0.0", + specialty=Specialty.HARDWARE, + description="Exports Windows optional-feature names and enabled states through PowerShell.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + default_profiles=("deep",), + timeout_seconds=60, + maximum_output_bytes=5 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Verify cancellation state and the PowerShell executable before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run a read-only feature query and register its JSON output when available.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before optional-feature collection") + context.report_progress("windows_features_started") + command = ( + "Get-WindowsOptionalFeature -Online | " + "Select-Object FeatureName, State | ConvertTo-Json -Depth 2" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=45, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "optional-feature access was denied for the current account", + errors=(detail,), + ) + return CollectorResult( + CollectorStatus.FAILED, + "PowerShell optional-feature query failed", + errors=(detail,), + ) + try: + payload = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult( + CollectorStatus.FAILED, + "PowerShell optional-feature query returned invalid JSON", + errors=(str(error),), + ) + features = payload if isinstance(payload, list) else [payload] + output = context.workspace / "windows_features.json" + output.write_text(json.dumps(features, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("windows_features_finished", feature_count=len(features), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Windows optional features collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the PowerShell command is synchronous.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index fa94ed4e..ab754412 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -23,6 +23,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.running_processes", "core.storage.logical_drives", "core.system.system_info", + "core.hardware.windows_features", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) diff --git a/tests/test_windows_features_collector.py b/tests/test_windows_features_collector.py new file mode 100644 index 00000000..189a1c3e --- /dev/null +++ b/tests/test_windows_features_collector.py @@ -0,0 +1,31 @@ +"""Permission-handling checks for the optional Windows-features collector.""" + +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +def _module(): + path = Path(__file__).resolve().parent.parent / "core" / "hardware" / "windows_features.py" + spec = importlib.util.spec_from_file_location("windows_features_test", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class WindowsFeaturesCollectorTests(unittest.TestCase): + """Ensure expected PowerShell permission restrictions do not fail a whole run.""" + + def test_power_shell_dism_access_error_is_recognized(self) -> None: + """DISM's 'access to ... is denied' wording must map to a skipped collector.""" + module = _module() + self.assertTrue( + module._is_access_denied("Set current directory failed: Access to the path is denied.") + ) + + +if __name__ == "__main__": + unittest.main() From 01d5b450a8fbdf2ea3a939b695e1e6e935435cf3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 03:41:14 +0300 Subject: [PATCH 008/316] feat: add network adapter collector --- README.md | 2 +- core/network/network_adapters.py | 82 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 core/network/network_adapters.py diff --git a/README.md b/README.md index 532e1ae7..75504def 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ rebuilt incrementally. - Shipped collectors: `core.system.system_info` and the capability-gated `core.process.running_processes` and `core.network.network_identity`, plus `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile - `core.hardware.windows_features`. + `core.hardware.windows_features`, plus `core.network.network_adapters`. ## Layout diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py new file mode 100644 index 00000000..deabf94f --- /dev/null +++ b/core/network/network_adapters.py @@ -0,0 +1,82 @@ +"""Collect the local Windows IP configuration as a bounded text artifact.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +class NetworkAdaptersCollector(CoreCollector): + """Run the local IP configuration command and register its output as evidence.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated adapter inventory and its fixed output bound.""" + return CollectorMetadata( + id="core.network.network_adapters", + name="Network adapters", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Captures local Windows IP configuration and adapter details through ipconfig.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + default_profiles=("standard", "deep"), + timeout_seconds=20, + maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Confirm cancellation state and that ipconfig is available on the host.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("ipconfig") is None: + return ValidationResult(False, reasons=("ipconfig is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Capture local adapter configuration and register the bounded plain-text report.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before adapter collection") + context.report_progress("network_adapters_started") + completed = subprocess.run( + ["ipconfig", "/all"], + capture_output=True, + check=False, + text=True, + timeout=15, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}" + if "permission denied" in detail.casefold() or ( + "access" in detail.casefold() and "denied" in detail.casefold() + ): + return CollectorResult( + CollectorStatus.SKIPPED, + "ipconfig access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "ipconfig failed", errors=(detail,)) + output = context.workspace / "network_adapters.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + adapter_sections = completed.stdout.casefold().count("adapter ") + context.report_progress( + "network_adapters_finished", + adapter_sections=adapter_sections, + bytes_written=artifact.size_bytes, + ) + return CollectorResult.succeeded("network adapter configuration collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the ipconfig child process has completed.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index ab754412..39f3c13d 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -19,6 +19,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: self.assertEqual( { "core.network.network_identity", + "core.network.network_adapters", "core.memory.memory_snapshot", "core.process.running_processes", "core.storage.logical_drives", From 8b1876809028e63fd50351254d589a45c714e506 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:24:41 +0300 Subject: [PATCH 009/316] feat: add Bluetooth device collector --- README.md | 3 +- core/bluetooth/paired_devices.py | 96 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 core/bluetooth/paired_devices.py diff --git a/README.md b/README.md index 75504def..4876572d 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ rebuilt incrementally. - Shipped collectors: `core.system.system_info` and the capability-gated `core.process.running_processes` and `core.network.network_identity`, plus `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile - `core.hardware.windows_features`, plus `core.network.network_adapters`. + `core.hardware.windows_features`, `core.network.network_adapters`, and + `core.bluetooth.paired_devices`. ## Layout diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py new file mode 100644 index 00000000..d0ef3ff2 --- /dev/null +++ b/core/bluetooth/paired_devices.py @@ -0,0 +1,96 @@ +"""Collect Windows Plug and Play metadata for Bluetooth devices.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize Windows and PowerShell access-denied messages.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class PairedDevicesCollector(CoreCollector): + """Export Bluetooth PnP device metadata without changing Bluetooth state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the read-only PnP query, subprocess permission, and bounded output.""" + return CollectorMetadata( + id="core.bluetooth.paired_devices", + name="Bluetooth devices", + version="4.0.0", + specialty=Specialty.BLUETOOTH, + description="Captures available Bluetooth Plug and Play device metadata through PowerShell.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before issuing the PnP query.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run a read-only Bluetooth PnP query and register its normalized JSON output.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth collection") + context.report_progress("bluetooth_devices_started") + command = ( + "Get-PnpDevice -Class Bluetooth | " + "Select-Object FriendlyName, InstanceId, Status, Class, Problem, Present | " + "ConvertTo-Json -Depth 2" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=20, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "Bluetooth PnP access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query failed", errors=(detail,)) + try: + payload = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult( + CollectorStatus.FAILED, + "Bluetooth PnP query returned invalid JSON", + errors=(str(error),), + ) + devices = payload if isinstance(payload, list) else [payload] + output = context.workspace / "bluetooth_devices.json" + output.write_text(json.dumps(devices, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("bluetooth_devices_finished", device_count=len(devices), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Bluetooth device metadata collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the PowerShell process has already exited.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 39f3c13d..e4ec23a5 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -18,6 +18,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: self.assertEqual((), report.invalid) self.assertEqual( { + "core.bluetooth.paired_devices", "core.network.network_identity", "core.network.network_adapters", "core.memory.memory_snapshot", From 3cf63fbd969c19155cd8155b30d1c8f9c325e9d8 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:26:04 +0300 Subject: [PATCH 010/316] feat: add USB storage registry collector --- README.md | 2 +- core/usb/usb_storage_inventory.py | 110 ++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 core/usb/usb_storage_inventory.py diff --git a/README.md b/README.md index 4876572d..c898c971 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ rebuilt incrementally. `core.process.running_processes` and `core.network.network_identity`, plus `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and - `core.bluetooth.paired_devices`. + `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. ## Layout diff --git a/core/usb/usb_storage_inventory.py b/core/usb/usb_storage_inventory.py new file mode 100644 index 00000000..93b666bd --- /dev/null +++ b/core/usb/usb_storage_inventory.py @@ -0,0 +1,110 @@ +"""Collect Windows USB storage device metadata from the read-only USBSTOR registry tree.""" + +from __future__ import annotations + +import json +import winreg +from datetime import datetime, timezone + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + +_USBSTOR_PATH = r"SYSTEM\CurrentControlSet\Enum\USBSTOR" + + +def _enumerate_usb_storage() -> list[dict[str, str]]: + """Return USBSTOR device class, instance ID, and friendly name when readable.""" + devices: list[dict[str, str]] = [] + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, _USBSTOR_PATH) as root: + class_index = 0 + while True: + try: + device_class = winreg.EnumKey(root, class_index) + except OSError: + break + class_index += 1 + class_path = f"{_USBSTOR_PATH}\\{device_class}" + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, class_path) as class_key: + instance_index = 0 + while True: + try: + instance_id = winreg.EnumKey(class_key, instance_index) + except OSError: + break + instance_index += 1 + instance_path = f"{class_path}\\{instance_id}" + friendly_name = instance_id + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, instance_path) as instance_key: + friendly_name = str(winreg.QueryValueEx(instance_key, "FriendlyName")[0]) + except OSError: + pass + devices.append( + { + "device_class": device_class, + "instance_id": instance_id, + "friendly_name": friendly_name, + } + ) + return devices + + +class UsbStorageInventoryCollector(CoreCollector): + """Register a bounded JSON inventory of USB storage devices visible in the registry.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the registry-read capability and device-metadata-only output scope.""" + return CollectorMetadata( + id="core.usb.usb_storage_inventory", + name="USB storage inventory", + version="4.0.0", + specialty=Specialty.USB, + description="Reads USB storage device class, instance ID, and friendly name from USBSTOR.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.REGISTRY_READ,), + default_profiles=("deep",), + timeout_seconds=20, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation and read access to the USBSTOR registry root.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, _USBSTOR_PATH): + return ValidationResult(True) + except OSError as error: + return ValidationResult(False, reasons=(f"USBSTOR registry access is unavailable: {error}",)) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Read USB storage metadata and register the resulting JSON artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before USB registry collection") + context.report_progress("usb_storage_inventory_started") + try: + devices = _enumerate_usb_storage() + except OSError as error: + return CollectorResult( + CollectorStatus.SKIPPED, + "USBSTOR registry access was denied during collection", + errors=(str(error),), + ) + report = {"collected_at": datetime.now(timezone.utc).isoformat(), "devices": devices} + output = context.workspace / "usb_storage_inventory.json" + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("usb_storage_inventory_finished", device_count=len(devices), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("USB storage inventory collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because registry handles are closed during enumeration.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index e4ec23a5..dcc6d520 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -25,6 +25,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.running_processes", "core.storage.logical_drives", "core.system.system_info", + "core.usb.usb_storage_inventory", "core.hardware.windows_features", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, From 3ffe48356482548539a62f5148c58daa811c8ee5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:29:57 +0300 Subject: [PATCH 011/316] feat: add installed driver collector --- README.md | 1 + core/system/installed_drivers.py | 82 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 84 insertions(+) create mode 100644 core/system/installed_drivers.py diff --git a/README.md b/README.md index c898c971..b75cc90a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ rebuilt incrementally. `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. + Deep-profile collection also includes `core.system.installed_drivers`. ## Layout diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py new file mode 100644 index 00000000..69b5ec28 --- /dev/null +++ b/core/system/installed_drivers.py @@ -0,0 +1,82 @@ +"""Collect a bounded CSV inventory of installed Windows device drivers.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common access-denied wording from Windows command output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class InstalledDriversCollector(CoreCollector): + """Capture a read-only driverquery CSV report through the artifact boundary.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the read-only subprocess capability and bounded driver-report output.""" + return CollectorMetadata( + id="core.system.installed_drivers", + name="Installed drivers", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports detailed Windows driver inventory information as CSV.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Verify cancellation state and driverquery availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("driverquery") is None: + return ValidationResult(False, reasons=("driverquery is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run driverquery and register its detailed CSV report when permitted.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before driver inventory collection") + context.report_progress("installed_drivers_started") + completed = subprocess.run( + ["driverquery", "/v", "/fo", "csv", "/nh"], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"driverquery exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "driverquery access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "driverquery failed", errors=(detail,)) + output = context.workspace / "installed_drivers.csv" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/csv") + driver_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("installed_drivers_finished", driver_count=driver_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("installed-driver inventory collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because driverquery has completed before return.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index dcc6d520..ccae55c3 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -25,6 +25,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.running_processes", "core.storage.logical_drives", "core.system.system_info", + "core.system.installed_drivers", "core.usb.usb_storage_inventory", "core.hardware.windows_features", }, From ecfab0f57e03080083af8f3b493bd922425e548a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:31:21 +0300 Subject: [PATCH 012/316] feat: add group policy collector --- README.md | 3 +- core/system/group_policy.py | 82 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 core/system/group_policy.py diff --git a/README.md b/README.md index b75cc90a..b7277a54 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ rebuilt incrementally. `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. - Deep-profile collection also includes `core.system.installed_drivers`. + Deep-profile collection also includes `core.system.installed_drivers` and + `core.system.group_policy`. ## Layout diff --git a/core/system/group_policy.py b/core/system/group_policy.py new file mode 100644 index 00000000..e5163062 --- /dev/null +++ b/core/system/group_policy.py @@ -0,0 +1,82 @@ +"""Collect the local Windows group-policy result report as bounded text evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from Windows command output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class GroupPolicyCollector(CoreCollector): + """Capture the local user/computer group-policy summary without changing policy state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the read-only subprocess capability and bounded text output contract.""" + return CollectorMetadata( + id="core.system.group_policy", + name="Group policy result", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports the local Windows Group Policy Result summary through gpresult.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and the presence of gpresult before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("gpresult") is None: + return ValidationResult(False, reasons=("gpresult is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run the read-only group-policy summary and register its text output.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before group-policy collection") + context.report_progress("group_policy_started") + completed = subprocess.run( + ["gpresult", "/r"], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"gpresult exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "group-policy access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "gpresult failed", errors=(detail,)) + output = context.workspace / "group_policy.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + line_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("group_policy_finished", line_count=line_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("group-policy summary collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because gpresult exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index ccae55c3..68f1e801 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -26,6 +26,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.storage.logical_drives", "core.system.system_info", "core.system.installed_drivers", + "core.system.group_policy", "core.usb.usb_storage_inventory", "core.hardware.windows_features", }, From 213fe04ca4651e3d05bf6e63ac65cf982c14183d Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:34:15 +0300 Subject: [PATCH 013/316] feat: add System event log collector --- README.md | 2 +- core/event_log/system_events.py | 90 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 core/event_log/system_events.py diff --git a/README.md b/README.md index b7277a54..e7671266 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ rebuilt incrementally. `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. Deep-profile collection also includes `core.system.installed_drivers` and - `core.system.group_policy`. + `core.system.group_policy`, and `core.event_log.system_events`. ## Layout diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py new file mode 100644 index 00000000..509e1273 --- /dev/null +++ b/core/event_log/system_events.py @@ -0,0 +1,90 @@ +"""Export a bounded Windows System event-log sample as CSV evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + +_MAX_EVENTS = 1_000 + + +def _is_access_denied(detail: str) -> bool: + """Recognize common access-denied wording emitted by PowerShell event queries.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class SystemEventsCollector(CoreCollector): + """Capture a bounded CSV sample of Windows System events without changing event logs.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated event-log sample and its output limit.""" + return CollectorMetadata( + id="core.event_log.system_events", + name="System event log", + version="4.0.0", + specialty=Specialty.EVENT_LOG, + description="Exports up to 1,000 local Windows System events as CSV.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("event_logs",), + default_profiles=("deep",), + timeout_seconds=90, + maximum_output_bytes=8 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before querying events.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query a bounded System event sample and register its CSV output.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before event-log collection") + context.report_progress("system_events_started", maximum_events=_MAX_EVENTS) + command = ( + f"Get-WinEvent -LogName System -MaxEvents {_MAX_EVENTS} | " + "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | " + "ConvertTo-Csv -NoTypeInformation" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=75, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "System event-log access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "System event-log query failed", errors=(detail,)) + output = context.workspace / "system_events.csv" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/csv") + event_count = max(0, len(completed.stdout.splitlines()) - 1) + context.report_progress("system_events_finished", event_count=event_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("System event-log sample collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the event query process has already exited.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 68f1e801..acef217f 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -29,6 +29,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.group_policy", "core.usb.usb_storage_inventory", "core.hardware.windows_features", + "core.event_log.system_events", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) From f47413754e57d34c915d717b4de276dce56b5b0a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:35:50 +0300 Subject: [PATCH 014/316] feat: add ARP cache collector --- README.md | 3 +- core/network/arp_cache.py | 83 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 core/network/arp_cache.py diff --git a/README.md b/README.md index e7671266..2e168187 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ rebuilt incrementally. `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. Deep-profile collection also includes `core.system.installed_drivers` and - `core.system.group_policy`, and `core.event_log.system_events`. + `core.system.group_policy`, `core.event_log.system_events`, and + `core.network.arp_cache`. ## Layout diff --git a/core/network/arp_cache.py b/core/network/arp_cache.py new file mode 100644 index 00000000..5f0797d6 --- /dev/null +++ b/core/network/arp_cache.py @@ -0,0 +1,83 @@ +"""Export the local Windows ARP cache as bounded, read-only network evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from Windows command output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ArpCacheCollector(CoreCollector): + """Capture current ARP mappings without probing or modifying the network.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated, bounded ARP-cache report.""" + return CollectorMetadata( + id="core.network.arp_cache", + name="ARP cache", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Exports the local ARP cache without sending network traffic.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=1 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and arp availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("arp") is None: + return ValidationResult(False, reasons=("arp is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run the read-only ARP query and register its text artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before ARP-cache collection") + context.report_progress("arp_cache_started") + completed = subprocess.run( + ["arp", "-a"], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"arp exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "ARP-cache access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "ARP-cache query failed", errors=(detail,)) + output = context.workspace / "arp_cache.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + line_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("arp_cache_finished", line_count=line_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("ARP-cache report collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because arp exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index acef217f..c94fe967 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -21,6 +21,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.bluetooth.paired_devices", "core.network.network_identity", "core.network.network_adapters", + "core.network.arp_cache", "core.memory.memory_snapshot", "core.process.running_processes", "core.storage.logical_drives", From 677e78076b1e317c9789206d322471e5a3f823e4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 16 Aug 2026 20:36:54 +0300 Subject: [PATCH 015/316] feat: add routing table collector --- README.md | 2 +- core/network/routing_table.py | 83 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 core/network/routing_table.py diff --git a/README.md b/README.md index 2e168187..6e6f6051 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ rebuilt incrementally. `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. Deep-profile collection also includes `core.system.installed_drivers` and `core.system.group_policy`, `core.event_log.system_events`, and - `core.network.arp_cache`. + `core.network.arp_cache`, plus `core.network.routing_table`. ## Layout diff --git a/core/network/routing_table.py b/core/network/routing_table.py new file mode 100644 index 00000000..776c7f17 --- /dev/null +++ b/core/network/routing_table.py @@ -0,0 +1,83 @@ +"""Export the local Windows routing table as bounded, read-only network evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from Windows command output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class RoutingTableCollector(CoreCollector): + """Capture the local routing table without changing routes or sending traffic.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated, bounded routing-table report.""" + return CollectorMetadata( + id="core.network.routing_table", + name="Routing table", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Exports the local IPv4 and IPv6 routing tables without changing routes.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=1 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and route availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("route") is None: + return ValidationResult(False, reasons=("route is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run the read-only route query and register its text artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before routing-table collection") + context.report_progress("routing_table_started") + completed = subprocess.run( + ["route", "print"], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"route exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "routing-table access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "routing-table query failed", errors=(detail,)) + output = context.workspace / "routing_table.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + line_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("routing_table_finished", line_count=line_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("routing-table report collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because route exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index c94fe967..8c9ca0ab 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -22,6 +22,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.network_identity", "core.network.network_adapters", "core.network.arp_cache", + "core.network.routing_table", "core.memory.memory_snapshot", "core.process.running_processes", "core.storage.logical_drives", From 930f2c39e6ff72cbdb11ba11a00e01bbaac1db99 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:30:23 +0300 Subject: [PATCH 016/316] feat: add system details collector --- README.md | 3 +- core/system/system_details.py | 83 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 core/system/system_details.py diff --git a/README.md b/README.md index 6e6f6051..64789cbd 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ rebuilt incrementally. `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. - Deep-profile collection also includes `core.system.installed_drivers` and + Deep-profile collection also includes `core.system.system_details`, + `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, plus `core.network.routing_table`. diff --git a/core/system/system_details.py b/core/system/system_details.py new file mode 100644 index 00000000..6db09033 --- /dev/null +++ b/core/system/system_details.py @@ -0,0 +1,83 @@ +"""Export the complete read-only Windows systeminfo report as an evidence artifact.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from Windows command output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class SystemDetailsCollector(CoreCollector): + """Capture the full systeminfo report without changing local system state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated system-details artifact contract.""" + return CollectorMetadata( + id="core.system.system_details", + name="System details", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports the complete Windows systeminfo report as text.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and systeminfo availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("systeminfo") is None: + return ValidationResult(False, reasons=("systeminfo is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run systeminfo and register its complete text output when permitted.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-details collection") + context.report_progress("system_details_started") + completed = subprocess.run( + ["systeminfo"], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"systeminfo exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "systeminfo access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "systeminfo failed", errors=(detail,)) + output = context.workspace / "system_details.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + line_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("system_details_finished", line_count=line_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("system-details report collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because systeminfo exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 8c9ca0ab..5daee1f2 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -27,6 +27,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.running_processes", "core.storage.logical_drives", "core.system.system_info", + "core.system.system_details", "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", From 5e7855953bd180795ff5e1e726a39d7167a90117 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:31:44 +0300 Subject: [PATCH 017/316] feat: add BIOS information collector --- README.md | 1 + core/system/bios_info.py | 125 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 127 insertions(+) create mode 100644 core/system/bios_info.py diff --git a/README.md b/README.md index 64789cbd..22b6c1a4 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ rebuilt incrementally. `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. Deep-profile collection also includes `core.system.system_details`, + `core.system.bios_info`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, plus `core.network.routing_table`. diff --git a/core/system/bios_info.py b/core/system/bios_info.py new file mode 100644 index 00000000..c9232ced --- /dev/null +++ b/core/system/bios_info.py @@ -0,0 +1,125 @@ +"""Export Windows BIOS manufacturer, name, and version as a bounded HTML table.""" + +from __future__ import annotations + +import html +import json +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +def _render_bios_table(bios: dict[str, object]) -> str: + """Render trusted structured BIOS fields into a portable evidence table.""" + rows = "\n".join( + f" {html.escape(label)}{html.escape(str(bios.get(key) or 'unavailable'))}" + for key, label in ( + ("Manufacturer", "Manufacturer"), + ("Name", "Name"), + ("SMBIOSBIOSVersion", "SMBIOS BIOS version"), + ("Version", "Version"), + ("ReleaseDate", "Release date"), + ) + ) + return "\n".join( + ( + "", + '', + " BIOS information", + " ", + "

BIOS information

", + " ", + " ", + f" \n{rows}\n ", + "
FieldValue
", + " ", + "", + "", + ) + ) + + +class BiosInfoCollector(CoreCollector): + """Capture a read-only BIOS inventory through modern Windows CIM data.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated BIOS HTML artifact contract.""" + return CollectorMetadata( + id="core.system.bios_info", + name="BIOS information", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports BIOS manufacturer, name, version, and release date as HTML.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query BIOS CIM data and register an HTML evidence table.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before BIOS collection") + context.report_progress("bios_info_started") + command = ( + "Get-CimInstance -ClassName Win32_BIOS | " + "Select-Object Manufacturer, Name, SMBIOSBIOSVersion, Version, ReleaseDate | " + "ConvertTo-Json -Compress" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "BIOS CIM access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "BIOS CIM query failed", errors=(detail,)) + try: + bios = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "BIOS CIM query returned invalid JSON", errors=(str(error),)) + if not isinstance(bios, dict): + return CollectorResult(CollectorStatus.FAILED, "BIOS CIM query returned an unexpected result") + output = context.workspace / "bios_info.html" + output.write_text(_render_bios_table(bios), encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/html") + context.report_progress("bios_info_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("BIOS information collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 5daee1f2..037113b8 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -28,6 +28,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.storage.logical_drives", "core.system.system_info", "core.system.system_details", + "core.system.bios_info", "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", From 19aa4ce9f2fc00145b29e35f391769b2f1524f89 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:32:55 +0300 Subject: [PATCH 018/316] feat: add operating system collector --- README.md | 1 + core/system/operating_system.py | 99 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 101 insertions(+) create mode 100644 core/system/operating_system.py diff --git a/README.md b/README.md index 22b6c1a4..1544ec3e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ rebuilt incrementally. `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. Deep-profile collection also includes `core.system.system_details`, `core.system.bios_info`, + `core.system.operating_system`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, plus `core.network.routing_table`. diff --git a/core/system/operating_system.py b/core/system/operating_system.py new file mode 100644 index 00000000..771d2c1c --- /dev/null +++ b/core/system/operating_system.py @@ -0,0 +1,99 @@ +"""Export detailed Windows operating-system CIM data as a bounded JSON artifact.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class OperatingSystemCollector(CoreCollector): + """Capture read-only Windows operating-system details through CIM.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated operating-system JSON artifact contract.""" + return CollectorMetadata( + id="core.system.operating_system", + name="Operating system details", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports detailed local Windows operating-system CIM information.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query operating-system CIM data and register a JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before operating-system collection") + context.report_progress("operating_system_started") + command = ( + "Get-CimInstance -ClassName Win32_OperatingSystem | " + "Select-Object Caption, CSDVersion, Version, BuildNumber, OSArchitecture, " + "InstallDate, LastBootUpTime, Locale, MUILanguages, SystemDirectory, WindowsDirectory | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "operating-system CIM access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "operating-system CIM query failed", errors=(detail,)) + try: + operating_system = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult( + CollectorStatus.FAILED, + "operating-system CIM query returned invalid JSON", + errors=(str(error),), + ) + if not isinstance(operating_system, dict): + return CollectorResult(CollectorStatus.FAILED, "operating-system CIM query returned an unexpected result") + output = context.workspace / "operating_system.json" + output.write_text(json.dumps(operating_system, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("operating_system_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("operating-system details collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 037113b8..dc663574 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -29,6 +29,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.system_info", "core.system.system_details", "core.system.bios_info", + "core.system.operating_system", "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", From afb2beff0cfd20c63d97b549ef11e88a317a470a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:34:22 +0300 Subject: [PATCH 019/316] feat: add computer system collector --- README.md | 1 + core/system/computer_system.py | 98 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 100 insertions(+) create mode 100644 core/system/computer_system.py diff --git a/README.md b/README.md index 1544ec3e..c52bacc1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ rebuilt incrementally. Deep-profile collection also includes `core.system.system_details`, `core.system.bios_info`, `core.system.operating_system`, + `core.system.computer_system`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, plus `core.network.routing_table`. diff --git a/core/system/computer_system.py b/core/system/computer_system.py new file mode 100644 index 00000000..45cf6dc6 --- /dev/null +++ b/core/system/computer_system.py @@ -0,0 +1,98 @@ +"""Export local Windows computer model, manufacturer, and processor-count CIM data.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ComputerSystemCollector(CoreCollector): + """Capture read-only local computer-system identity and hardware metadata.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated computer-system JSON artifact contract.""" + return CollectorMetadata( + id="core.system.computer_system", + name="Computer system", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports local computer model, manufacturer, and processor-count CIM data.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query computer-system CIM data and register a JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before computer-system collection") + context.report_progress("computer_system_started") + command = ( + "Get-CimInstance -ClassName Win32_ComputerSystem | " + "Select-Object Name, Manufacturer, Model, SystemType, NumberOfProcessors, " + "NumberOfLogicalProcessors, TotalPhysicalMemory | ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "computer-system CIM access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "computer-system CIM query failed", errors=(detail,)) + try: + computer_system = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult( + CollectorStatus.FAILED, + "computer-system CIM query returned invalid JSON", + errors=(str(error),), + ) + if not isinstance(computer_system, dict): + return CollectorResult(CollectorStatus.FAILED, "computer-system CIM query returned an unexpected result") + output = context.workspace / "computer_system.json" + output.write_text(json.dumps(computer_system, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("computer_system_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("computer-system details collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index dc663574..e3a059f4 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -30,6 +30,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.system_details", "core.system.bios_info", "core.system.operating_system", + "core.system.computer_system", "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", From fe1c529aee742ca1b94f0738d3c24d8fb5ca4b0e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:36:21 +0300 Subject: [PATCH 020/316] feat: add physical disks collector --- README.md | 3 +- core/storage/physical_disks.py | 84 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 core/storage/physical_disks.py diff --git a/README.md b/README.md index c52bacc1..110f9147 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,8 @@ rebuilt incrementally. `core.system.computer_system`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and - `core.network.arp_cache`, plus `core.network.routing_table`. + `core.network.arp_cache`, `core.network.routing_table`, and + `core.storage.physical_disks`. ## Layout diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py new file mode 100644 index 00000000..5d01bcdd --- /dev/null +++ b/core/storage/physical_disks.py @@ -0,0 +1,84 @@ +"""Export local Windows physical disk model and capacity CIM data as bounded JSON.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class PhysicalDisksCollector(CoreCollector): + """Capture a read-only inventory of locally attached physical disk hardware.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated physical-disk JSON artifact contract.""" + return CollectorMetadata( + id="core.storage.physical_disks", + name="Physical disks", + version="4.0.0", + specialty=Specialty.STORAGE, + description="Exports local physical disk models, media types, interface types, and sizes.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query physical-disk CIM data and register a JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before physical-disk collection") + context.report_progress("physical_disks_started") + command = ( + "Get-CimInstance -ClassName Win32_DiskDrive | " + "Select-Object Model, Size, MediaType, InterfaceType, DeviceID, Partitions | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "physical-disk CIM access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query failed", errors=(detail,)) + try: + physical_disks = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned invalid JSON", errors=(str(error),)) + if not isinstance(physical_disks, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned an unexpected result") + output = context.workspace / "physical_disks.json" + output.write_text(json.dumps(physical_disks, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(physical_disks) if isinstance(physical_disks, list) else 1 + context.report_progress("physical_disks_finished", disk_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("physical-disk inventory collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index e3a059f4..98106bd3 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -26,6 +26,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.memory.memory_snapshot", "core.process.running_processes", "core.storage.logical_drives", + "core.storage.physical_disks", "core.system.system_info", "core.system.system_details", "core.system.bios_info", From c34dad9044bc1ea3872af6bd9f2cfb085bf73609 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:37:52 +0300 Subject: [PATCH 021/316] feat: add session snapshot collector --- README.md | 1 + core/system/session_snapshot.py | 92 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 94 insertions(+) create mode 100644 core/system/session_snapshot.py diff --git a/README.md b/README.md index 110f9147..ad817eaa 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ rebuilt incrementally. `core.system.bios_info`, `core.system.operating_system`, `core.system.computer_system`, + `core.system.session_snapshot`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py new file mode 100644 index 00000000..6df56fc6 --- /dev/null +++ b/core/system/session_snapshot.py @@ -0,0 +1,92 @@ +"""Export a bounded Windows session and system-state snapshot as JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class SessionSnapshotCollector(CoreCollector): + """Capture read-only host, user, memory, locale, and system-drive metadata.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated session snapshot artifact contract.""" + return CollectorMetadata( + id="core.system.session_snapshot", + name="Session snapshot", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports current user/SID, OS build, memory, language, host, time, and system-drive data.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("user_identity", "system_configuration"), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query bounded session fields and register their JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before session-snapshot collection") + context.report_progress("session_snapshot_started") + command = ( + "$os = Get-CimInstance -ClassName Win32_OperatingSystem; " + "$identity = whoami /user /fo csv /nh | ConvertFrom-Csv; " + "[pscustomobject]@{ " + "ComputerName = $env:COMPUTERNAME; CurrentUser = $identity.'User Name'; Sid = $identity.SID; " + "WindowsBuild = $os.BuildNumber; Version = $os.Version; " + "PhysicalMemoryBytes = $os.TotalVisibleMemorySize * 1KB; " + "VirtualMemoryBytes = $os.TotalVirtualMemorySize * 1KB; " + "FreePhysicalMemoryBytes = $os.FreePhysicalMemory * 1KB; " + "LanguageIds = [System.Globalization.CultureInfo]::CurrentCulture.LCID; " + "UiLanguage = [System.Globalization.CultureInfo]::CurrentUICulture.Name; " + "CollectedAt = [DateTime]::UtcNow.ToString('o'); SystemDrive = $env:SystemDrive " + "} | ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "session-snapshot access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "session-snapshot query failed", errors=(detail,)) + try: + snapshot = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned invalid JSON", errors=(str(error),)) + if not isinstance(snapshot, dict): + return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned an unexpected result") + output = context.workspace / "session_snapshot.json" + output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("session_snapshot_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("session snapshot collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 98106bd3..42eac04b 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -32,6 +32,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.bios_info", "core.system.operating_system", "core.system.computer_system", + "core.system.session_snapshot", "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", From 5b6493ebf298d8d3a31b0bddf965fbf036f81e92 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:38:58 +0300 Subject: [PATCH 022/316] feat: add detailed process collector --- README.md | 1 + core/process/detailed_processes.py | 72 ++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 74 insertions(+) create mode 100644 core/process/detailed_processes.py diff --git a/README.md b/README.md index ad817eaa..4d3fd5b3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ rebuilt incrementally. `core.system.operating_system`, `core.system.computer_system`, `core.system.session_snapshot`, + `core.process.detailed_processes`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py new file mode 100644 index 00000000..0390b5c2 --- /dev/null +++ b/core/process/detailed_processes.py @@ -0,0 +1,72 @@ +"""Export the verbose Windows task list as bounded CSV process evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from tasklist output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class DetailedProcessesCollector(CoreCollector): + """Capture a read-only verbose tasklist CSV report without changing processes.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated detailed process-report contract.""" + return CollectorMetadata( + id="core.process.detailed_processes", + name="Detailed running processes", + version="4.0.0", + specialty=Specialty.PROCESS, + description="Exports the verbose local Windows task list as CSV.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("process_metadata",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=8 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and tasklist availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("tasklist") is None: + return ValidationResult(False, reasons=("tasklist is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run the verbose task list and register its bounded CSV evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before detailed-process collection") + context.report_progress("detailed_processes_started") + completed = subprocess.run( + ["tasklist", "/v", "/fo", "csv", "/nh"], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"tasklist exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "tasklist access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "detailed tasklist query failed", errors=(detail,)) + output = context.workspace / "detailed_processes.csv" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/csv") + process_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) + context.report_progress("detailed_processes_finished", process_count=process_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("detailed process list collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because tasklist exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 42eac04b..a7c2ad30 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -25,6 +25,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.routing_table", "core.memory.memory_snapshot", "core.process.running_processes", + "core.process.detailed_processes", "core.storage.logical_drives", "core.storage.physical_disks", "core.system.system_info", From 3dabd1d829d35f419ccbc9d86bf44e1781381b55 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:40:35 +0300 Subject: [PATCH 023/316] feat: add active connections collector --- README.md | 1 + core/network/active_connections.py | 72 ++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 74 insertions(+) create mode 100644 core/network/active_connections.py diff --git a/README.md b/README.md index 4d3fd5b3..9ea3ce45 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ rebuilt incrementally. `core.system.computer_system`, `core.system.session_snapshot`, `core.process.detailed_processes`, + `core.network.active_connections`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/network/active_connections.py b/core/network/active_connections.py new file mode 100644 index 00000000..399d4a77 --- /dev/null +++ b/core/network/active_connections.py @@ -0,0 +1,72 @@ +"""Export local active Windows connections and owning PIDs as bounded text evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from netstat output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ActiveConnectionsCollector(CoreCollector): + """Capture active TCP and UDP endpoint metadata without network probing or changes.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated active-connection artifact contract.""" + return CollectorMetadata( + id="core.network.active_connections", + name="Active network connections", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Exports active TCP/UDP endpoints, states, and owning PIDs from netstat.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers", "process_metadata"), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and netstat availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("netstat") is None: + return ValidationResult(False, reasons=("netstat is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run the read-only connection query and register its text evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection collection") + context.report_progress("active_connections_started") + completed = subprocess.run( + ["netstat", "-ano"], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "netstat access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "active-connection query failed", errors=(detail,)) + output = context.workspace / "active_connections.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + connection_count = sum(1 for line in completed.stdout.splitlines() if line.lstrip().startswith(("TCP", "UDP"))) + context.report_progress("active_connections_finished", connection_count=connection_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("active network connections collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because netstat exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index a7c2ad30..f6991afa 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -20,6 +20,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: { "core.bluetooth.paired_devices", "core.network.network_identity", + "core.network.active_connections", "core.network.network_adapters", "core.network.arp_cache", "core.network.routing_table", From 8a657bafe0f1b7ba0a926c818338f9a626106f46 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:42:01 +0300 Subject: [PATCH 024/316] feat: add adapter statistics collector --- README.md | 1 + core/network/adapter_statistics.py | 85 ++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 87 insertions(+) create mode 100644 core/network/adapter_statistics.py diff --git a/README.md b/README.md index 9ea3ce45..214806ca 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ rebuilt incrementally. `core.system.session_snapshot`, `core.process.detailed_processes`, `core.network.active_connections`, + `core.network.adapter_statistics`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py new file mode 100644 index 00000000..9f503153 --- /dev/null +++ b/core/network/adapter_statistics.py @@ -0,0 +1,85 @@ +"""Export local Windows network adapter I/O counters as bounded JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class AdapterStatisticsCollector(CoreCollector): + """Capture read-only per-interface byte, packet, error, and discard counters.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated adapter-statistics artifact contract.""" + return CollectorMetadata( + id="core.network.adapter_statistics", + name="Network adapter statistics", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Exports per-interface network byte, packet, error, and discard counters.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query adapter counters and register their JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before adapter-statistics collection") + context.report_progress("adapter_statistics_started") + command = ( + "Get-NetAdapterStatistics | " + "Select-Object Name, ReceivedBytes, SentBytes, ReceivedUnicastPackets, SentUnicastPackets, " + "ReceivedDiscardedPackets, OutboundDiscardedPackets, ReceivedPacketErrors, OutboundPacketErrors | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "adapter-statistics access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query failed", errors=(detail,)) + try: + statistics = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned invalid JSON", errors=(str(error),)) + if not isinstance(statistics, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned an unexpected result") + output = context.workspace / "adapter_statistics.json" + output.write_text(json.dumps(statistics, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(statistics) if isinstance(statistics, list) else 1 + context.report_progress("adapter_statistics_finished", interface_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("network adapter statistics collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index f6991afa..6c885494 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -21,6 +21,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.bluetooth.paired_devices", "core.network.network_identity", "core.network.active_connections", + "core.network.adapter_statistics", "core.network.network_adapters", "core.network.arp_cache", "core.network.routing_table", From fe7ea997af73fa6c89eaa4ed9522f57626090183 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:43:51 +0300 Subject: [PATCH 025/316] feat: add network interfaces collector --- README.md | 1 + core/network/network_interfaces.py | 109 +++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 111 insertions(+) create mode 100644 core/network/network_interfaces.py diff --git a/README.md b/README.md index 214806ca..2cb3b779 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ rebuilt incrementally. `core.process.detailed_processes`, `core.network.active_connections`, `core.network.adapter_statistics`, + `core.network.network_interfaces`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py new file mode 100644 index 00000000..a24f5ea9 --- /dev/null +++ b/core/network/network_interfaces.py @@ -0,0 +1,109 @@ +"""Export local Windows interface addresses and link state as bounded JSON evidence.""" + +from __future__ import annotations + +import ipaddress +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +def _enrich_ipv4_networks(records: list[dict[str, object]]) -> list[dict[str, object]]: + """Add deterministic IPv4 netmask and broadcast fields from each prefix length.""" + enriched: list[dict[str, object]] = [] + for record in records: + item = dict(record) + address = item.get("IPAddress") + prefix = item.get("PrefixLength") + if isinstance(address, str) and isinstance(prefix, int): + try: + network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False) + except ValueError: + pass + else: + item["Netmask"] = str(network.netmask) + item["BroadcastAddress"] = str(network.broadcast_address) + enriched.append(item) + return enriched + + +class NetworkInterfacesCollector(CoreCollector): + """Capture read-only IPv4 addresses, masks, broadcasts, and adapter link metadata.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated network-interface JSON artifact contract.""" + return CollectorMetadata( + id="core.network.network_interfaces", + name="Network interfaces", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Exports IPv4 addresses, masks, broadcasts, link states, speeds, and duplex data.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query interface address and link data, then register JSON evidence.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before network-interface collection") + context.report_progress("network_interfaces_started") + command = ( + "$adapters = Get-NetAdapter; " + "Get-NetIPAddress -AddressFamily IPv4 | ForEach-Object { " + "$adapter = $adapters | Where-Object ifIndex -eq $_.InterfaceIndex | Select-Object -First 1; " + "[pscustomobject]@{ InterfaceAlias = $_.InterfaceAlias; IPAddress = $_.IPAddress; " + "PrefixLength = $_.PrefixLength; AddressState = $_.AddressState.ToString(); " + "Status = $adapter.Status.ToString(); LinkSpeed = $adapter.LinkSpeed; " + "MediaConnectionState = $adapter.MediaConnectionState.ToString(); FullDuplex = $adapter.FullDuplex } " + "} | ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "network-interface access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "network-interface query failed", errors=(detail,)) + try: + interfaces = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "network-interface query returned invalid JSON", errors=(str(error),)) + records = interfaces if isinstance(interfaces, list) else [interfaces] + if not all(isinstance(record, dict) for record in records): + return CollectorResult(CollectorStatus.FAILED, "network-interface query returned an unexpected result") + output = context.workspace / "network_interfaces.json" + output.write_text(json.dumps(_enrich_ipv4_networks(records), indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("network_interfaces_finished", interface_count=len(records), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("network-interface inventory collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 6c885494..417bc8bf 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -22,6 +22,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.network_identity", "core.network.active_connections", "core.network.adapter_statistics", + "core.network.network_interfaces", "core.network.network_adapters", "core.network.arp_cache", "core.network.routing_table", From 137c8f0a35b567d76e41cee39a2eded0e81a7965 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:45:39 +0300 Subject: [PATCH 026/316] feat: add connection process collector --- README.md | 1 + core/network/connection_processes.py | 103 +++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 105 insertions(+) create mode 100644 core/network/connection_processes.py diff --git a/README.md b/README.md index 2cb3b779..e493411a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ rebuilt incrementally. `core.network.active_connections`, `core.network.adapter_statistics`, `core.network.network_interfaces`, + `core.network.connection_processes`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py new file mode 100644 index 00000000..19c7a5c5 --- /dev/null +++ b/core/network/connection_processes.py @@ -0,0 +1,103 @@ +"""Export local active connections correlated with process names as bounded CSV evidence.""" + +from __future__ import annotations + +import csv +import io +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from Windows command output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +def _parse_connections(netstat_output: str) -> list[dict[str, str]]: + """Parse the stable token layout of Windows netstat -ano connection rows.""" + connections: list[dict[str, str]] = [] + for line in netstat_output.splitlines(): + fields = line.split() + if not fields or fields[0] not in {"TCP", "UDP"}: + continue + if fields[0] == "TCP" and len(fields) >= 5: + protocol, local, remote, state, pid = fields[:5] + elif fields[0] == "UDP" and len(fields) >= 4: + protocol, local, remote, pid = fields[:4] + state = "" + else: + continue + connections.append({"protocol": protocol, "local_endpoint": local, "remote_endpoint": remote, "state": state, "pid": pid}) + return connections + + +def _parse_processes(tasklist_output: str) -> dict[str, str]: + """Map tasklist CSV process identifiers to their image names.""" + processes: dict[str, str] = {} + for row in csv.reader(io.StringIO(tasklist_output)): + if len(row) >= 2 and row[1].isdigit(): + processes[row[1]] = row[0] + return processes + + +class ConnectionProcessesCollector(CoreCollector): + """Capture active connection endpoints with the local process name for each PID.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated connection-process CSV artifact contract.""" + return CollectorMetadata( + id="core.network.connection_processes", + name="Connection process associations", + version="4.0.0", + specialty=Specialty.NETWORK, + description="Correlates active Netstat TCP/UDP endpoints with local process names and PIDs.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers", "process_metadata"), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and required Windows command availability.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + missing = tuple(command for command in ("netstat", "tasklist") if which(command) is None) + if missing: + return ValidationResult(False, reasons=(f"required command is unavailable: {', '.join(missing)}",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Read Netstat and Tasklist data, then register their correlation as CSV.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection-process collection") + context.report_progress("connection_processes_started") + netstat = subprocess.run(["netstat", "-ano"], capture_output=True, check=False, text=True, timeout=25) + tasklist = subprocess.run(["tasklist", "/fo", "csv", "/nh"], capture_output=True, check=False, text=True, timeout=25) + failures = [result for result in (netstat, tasklist) if result.returncode != 0] + if failures: + detail = "\n".join(result.stderr.strip() or f"command exit code {result.returncode}" for result in failures) + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "connection-process access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "connection-process query failed", errors=(detail,)) + processes = _parse_processes(tasklist.stdout) + rows = _parse_connections(netstat.stdout) + output = context.workspace / "connection_processes.csv" + with output.open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=("protocol", "local_endpoint", "remote_endpoint", "state", "pid", "process_name")) + writer.writeheader() + for row in rows: + writer.writerow({**row, "process_name": processes.get(row["pid"], "unavailable")}) + artifact = context.artifacts.register_file(output, media_type="text/csv") + context.report_progress("connection_processes_finished", connection_count=len(rows), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("connection-process associations collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because both commands exit before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 417bc8bf..cad1ca41 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -21,6 +21,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.bluetooth.paired_devices", "core.network.network_identity", "core.network.active_connections", + "core.network.connection_processes", "core.network.adapter_statistics", "core.network.network_interfaces", "core.network.network_adapters", From 31dad33323299155f33ffeb40b4dcef9aa506c72 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:47:12 +0300 Subject: [PATCH 027/316] feat: add Wi-Fi profile collector --- README.md | 1 + core/wireless/wifi_profiles.py | 72 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 74 insertions(+) create mode 100644 core/wireless/wifi_profiles.py diff --git a/README.md b/README.md index e493411a..734f45ad 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ rebuilt incrementally. `core.network.adapter_statistics`, `core.network.network_interfaces`, `core.network.connection_processes`, + `core.wireless.wifi_profiles`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py new file mode 100644 index 00000000..2b360f30 --- /dev/null +++ b/core/wireless/wifi_profiles.py @@ -0,0 +1,72 @@ +"""Export saved Windows Wi-Fi profile names as bounded, read-only text evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from netsh output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class WifiProfilesCollector(CoreCollector): + """Capture saved wireless profile names without reading profile key material.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated Wi-Fi profile-name artifact contract.""" + return CollectorMetadata( + id="core.wireless.wifi_profiles", + name="Saved Wi-Fi profiles", + version="4.0.0", + specialty=Specialty.WIRELESS, + description="Exports local saved Wi-Fi profile names without retrieving key material.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("wireless_profile_names",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and netsh availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("netsh") is None: + return ValidationResult(False, reasons=("netsh is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """List saved Wi-Fi profiles and register their read-only text artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Wi-Fi profile collection") + context.report_progress("wifi_profiles_started") + completed = subprocess.run( + ["netsh", "wlan", "show", "profiles"], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"netsh exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi profile access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile query failed", errors=(detail,)) + output = context.workspace / "wifi_profiles.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + profile_count = sum(1 for line in completed.stdout.splitlines() if " : " in line) + context.report_progress("wifi_profiles_finished", profile_count=profile_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("saved Wi-Fi profiles collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because netsh exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index cad1ca41..ce4668a2 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -41,6 +41,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", + "core.wireless.wifi_profiles", "core.hardware.windows_features", "core.event_log.system_events", }, From 69842b7fae2f0a3a3eca9f28b1b212dd198f3d15 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:48:45 +0300 Subject: [PATCH 028/316] feat: add Application event log collector --- README.md | 1 + core/event_log/application_events.py | 79 ++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 81 insertions(+) create mode 100644 core/event_log/application_events.py diff --git a/README.md b/README.md index 734f45ad..bb4d7c4a 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ rebuilt incrementally. `core.network.network_interfaces`, `core.network.connection_processes`, `core.wireless.wifi_profiles`, + `core.event_log.application_events`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py new file mode 100644 index 00000000..70c9e2b7 --- /dev/null +++ b/core/event_log/application_events.py @@ -0,0 +1,79 @@ +"""Export a bounded Windows Application event-log sample as CSV evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_MAX_EVENTS = 1_000 + + +def _is_access_denied(detail: str) -> bool: + """Recognize common access-denied wording emitted by PowerShell event queries.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ApplicationEventsCollector(CoreCollector): + """Capture a bounded CSV sample of Application events without changing event logs.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated Application-event sample and output limit.""" + return CollectorMetadata( + id="core.event_log.application_events", + name="Application event log", + version="4.0.0", + specialty=Specialty.EVENT_LOG, + description="Exports up to 1,000 local Windows Application events as CSV.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("event_logs",), + default_profiles=("deep",), + timeout_seconds=90, + maximum_output_bytes=8 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before querying events.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query a bounded Application-event sample and register its CSV output.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Application event-log collection") + context.report_progress("application_events_started", maximum_events=_MAX_EVENTS) + command = ( + f"Get-WinEvent -LogName Application -MaxEvents {_MAX_EVENTS} | " + "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | " + "ConvertTo-Csv -NoTypeInformation" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=75, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Application event-log access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Application event-log query failed", errors=(detail,)) + output = context.workspace / "application_events.csv" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/csv") + event_count = max(0, len(completed.stdout.splitlines()) - 1) + context.report_progress("application_events_finished", event_count=event_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Application event-log sample collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the event query process has already exited.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index ce4668a2..6f661eb5 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -44,6 +44,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.wireless.wifi_profiles", "core.hardware.windows_features", "core.event_log.system_events", + "core.event_log.application_events", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) From b19bad8b93356fe570b77ff523683b2bb614df06 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:50:29 +0300 Subject: [PATCH 029/316] feat: add mounted volumes collector --- README.md | 2 +- core/storage/mounted_volumes.py | 66 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 core/storage/mounted_volumes.py diff --git a/README.md b/README.md index bb4d7c4a..51bacfd4 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ rebuilt incrementally. `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and - `core.storage.physical_disks`. + `core.storage.physical_disks`, and `core.storage.mounted_volumes`. ## Layout diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py new file mode 100644 index 00000000..9d7fcda0 --- /dev/null +++ b/core/storage/mounted_volumes.py @@ -0,0 +1,66 @@ +"""Export mounted Windows volume GUID mappings as bounded, read-only text evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from mountvol output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class MountedVolumesCollector(CoreCollector): + """Capture mounted volume GUID mappings without changing any mount points.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated mounted-volume text artifact contract.""" + return CollectorMetadata( + id="core.storage.mounted_volumes", + name="Mounted volumes", + version="4.0.0", + specialty=Specialty.STORAGE, + description="Exports Windows mounted volume GUID and mount-point mappings from mountvol.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and mountvol availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("mountvol") is None: + return ValidationResult(False, reasons=("mountvol is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run read-only mountvol and register its bounded text evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before mounted-volume collection") + context.report_progress("mounted_volumes_started") + completed = subprocess.run(["mountvol"], capture_output=True, check=False, text=True, timeout=25) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"mountvol exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "mounted-volume access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "mounted-volume query failed", errors=(detail,)) + output = context.workspace / "mounted_volumes.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + volume_count = sum(1 for line in completed.stdout.splitlines() if line.strip().startswith("\\\\?\\Volume{")) + context.report_progress("mounted_volumes_finished", volume_count=volume_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("mounted-volume mappings collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because mountvol exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 6f661eb5..a4c0a9c7 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -32,6 +32,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.detailed_processes", "core.storage.logical_drives", "core.storage.physical_disks", + "core.storage.mounted_volumes", "core.system.system_info", "core.system.system_details", "core.system.bios_info", From acfca4b02cf7664df73cc1e9ca1bb8c03447402e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:52:47 +0300 Subject: [PATCH 030/316] feat: add BitLocker status collector --- README.md | 1 + core/encryption/bitlocker_status.py | 69 +++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 71 insertions(+) create mode 100644 core/encryption/bitlocker_status.py diff --git a/README.md b/README.md index 51bacfd4..9ca9ca3e 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ rebuilt incrementally. `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and `core.storage.physical_disks`, and `core.storage.mounted_volumes`. + `core.encryption.bitlocker_status` is also available in the deep profile. ## Layout diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py new file mode 100644 index 00000000..d5e74672 --- /dev/null +++ b/core/encryption/bitlocker_status.py @@ -0,0 +1,69 @@ +"""Export local BitLocker status as bounded, read-only text evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str, return_code: int) -> bool: + """Recognize textual and HRESULT access-denied results from manage-bde.""" + normalized = detail.casefold() + return ( + return_code == 2147749891 + or "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + ) + + +class BitlockerStatusCollector(CoreCollector): + """Capture local BitLocker status without changing protectors or encryption state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated BitLocker-status artifact contract.""" + return CollectorMetadata( + id="core.encryption.bitlocker_status", + name="BitLocker status", + version="4.0.0", + specialty=Specialty.ENCRYPTION, + description="Exports local drive BitLocker status through the read-only manage-bde command.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("encryption_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and manage-bde availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("manage-bde") is None: + return ValidationResult(False, reasons=("manage-bde is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run read-only BitLocker status and register its text evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before BitLocker-status collection") + context.report_progress("bitlocker_status_started") + completed = subprocess.run(["manage-bde", "-status"], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"manage-bde exit code {completed.returncode}" + if _is_access_denied(detail, completed.returncode): + return CollectorResult(CollectorStatus.SKIPPED, "BitLocker status access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "BitLocker status query failed", errors=(detail,)) + output = context.workspace / "bitlocker_status.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + context.report_progress("bitlocker_status_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("BitLocker status collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because manage-bde exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index a4c0a9c7..c13eff94 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -46,6 +46,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.hardware.windows_features", "core.event_log.system_events", "core.event_log.application_events", + "core.encryption.bitlocker_status", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) From 196ceddf52d6f139169aac0f25dfa6c6419f8dfd Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:55:07 +0300 Subject: [PATCH 031/316] feat: add BitLocker volumes collector --- README.md | 1 + core/encryption/bitlocker_volumes.py | 85 ++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 87 insertions(+) create mode 100644 core/encryption/bitlocker_volumes.py diff --git a/README.md b/README.md index 9ca9ca3e..9407c535 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ rebuilt incrementally. `core.network.arp_cache`, `core.network.routing_table`, and `core.storage.physical_disks`, and `core.storage.mounted_volumes`. `core.encryption.bitlocker_status` is also available in the deep profile. + `core.encryption.bitlocker_volumes` provides the PowerShell volume view. ## Layout diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py new file mode 100644 index 00000000..5ca90118 --- /dev/null +++ b/core/encryption/bitlocker_volumes.py @@ -0,0 +1,85 @@ +"""Export PowerShell BitLocker volume data as bounded, read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class BitlockerVolumesCollector(CoreCollector): + """Capture local BitLocker-volume metadata without changing encryption configuration.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated BitLocker-volume JSON artifact contract.""" + return CollectorMetadata( + id="core.encryption.bitlocker_volumes", + name="BitLocker volumes", + version="4.0.0", + specialty=Specialty.ENCRYPTION, + description="Exports local BitLocker volume metadata through read-only Get-BitLockerVolume.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("encryption_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query BitLocker volumes and register their JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before BitLocker-volume collection") + context.report_progress("bitlocker_volumes_started") + command = ( + "$ErrorActionPreference = 'Stop'; " + "ConvertTo-Json -InputObject @(Get-BitLockerVolume | " + "Select-Object MountPoint, VolumeType, VolumeStatus, ProtectionStatus, EncryptionMethod, " + "EncryptionPercentage, LockStatus, AutoUnlockEnabled) -Depth 4" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "BitLocker volume access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query failed", errors=(detail,)) + try: + volumes = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned invalid JSON", errors=(str(error),)) + if not isinstance(volumes, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned an unexpected result") + output = context.workspace / "bitlocker_volumes.json" + output.write_text(json.dumps(volumes, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(volumes) if isinstance(volumes, list) else 1 + context.report_progress("bitlocker_volumes_finished", volume_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("BitLocker volumes collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index c13eff94..0f9f989a 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -47,6 +47,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.event_log.system_events", "core.event_log.application_events", "core.encryption.bitlocker_status", + "core.encryption.bitlocker_volumes", }, {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) From 05b4d5dd7c573bffb8db4dc2399f2d398c29764b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:57:32 +0300 Subject: [PATCH 032/316] feat: add system diagnostics collector --- README.md | 1 + core/system/system_diagnostics.py | 73 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 75 insertions(+) create mode 100644 core/system/system_diagnostics.py diff --git a/README.md b/README.md index 9407c535..8a9ac39e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ rebuilt incrementally. `core.system.operating_system`, `core.system.computer_system`, `core.system.session_snapshot`, + `core.system.system_diagnostics`, `core.process.detailed_processes`, `core.network.active_connections`, `core.network.adapter_statistics`, diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py new file mode 100644 index 00000000..deaa2aa8 --- /dev/null +++ b/core/system/system_diagnostics.py @@ -0,0 +1,73 @@ +"""Export Windows hardware, CPU, page-size, and boot-time diagnostics as bounded JSON.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class SystemDiagnosticsCollector(CoreCollector): + """Capture read-only CPU, architecture, page-size, and boot-time diagnostics.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated system-diagnostics JSON artifact contract.""" + return CollectorMetadata( + id="core.system.system_diagnostics", name="System diagnostics", version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports architecture, CPU, page-size, and boot-time diagnostics through Windows CIM.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), + timeout_seconds=45, maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query diagnostics and register their bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-diagnostics collection") + context.report_progress("system_diagnostics_started") + command = ( + "$ErrorActionPreference = 'Stop'; $os = Get-CimInstance -ClassName Win32_OperatingSystem; " + "$cpu = @(Get-CimInstance -ClassName Win32_Processor | Select-Object Name, Architecture, AddressWidth, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed); " + "[pscustomobject]@{ OperatingSystem = $os.Caption; Version = $os.Version; Architecture = $os.OSArchitecture; " + "Machine = $env:PROCESSOR_IDENTIFIER; PageSizeBytes = [Environment]::SystemPageSize; ProcessorCount = $cpu.Count; " + "Processors = $cpu; LastBootUpTime = $os.LastBootUpTime; CollectedAt = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 5" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "system-diagnostics access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query failed", errors=(detail,)) + try: + diagnostics = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned invalid JSON", errors=(str(error),)) + if not isinstance(diagnostics, dict): + return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned an unexpected result") + output = context.workspace / "system_diagnostics.json" + output.write_text(json.dumps(diagnostics, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("system_diagnostics_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("system diagnostics collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 0f9f989a..ff6ef5cd 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -39,6 +39,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.operating_system", "core.system.computer_system", "core.system.session_snapshot", + "core.system.system_diagnostics", "core.system.installed_drivers", "core.system.group_policy", "core.usb.usb_storage_inventory", From 5246fe4961391d85480633d2308b3bf4612bb803 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 00:59:10 +0300 Subject: [PATCH 033/316] feat: add volume details collector --- README.md | 1 + core/storage/volume_details.py | 90 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 92 insertions(+) create mode 100644 core/storage/volume_details.py diff --git a/README.md b/README.md index 8a9ac39e..3bb152ec 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ rebuilt incrementally. `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and `core.storage.physical_disks`, and `core.storage.mounted_volumes`. + `core.storage.volume_details` adds filesystem and label metadata in the deep profile. `core.encryption.bitlocker_status` is also available in the deep profile. `core.encryption.bitlocker_volumes` provides the PowerShell volume view. diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py new file mode 100644 index 00000000..98b7184d --- /dev/null +++ b/core/storage/volume_details.py @@ -0,0 +1,90 @@ +"""Export detailed mounted Windows volume metadata through bounded native API calls.""" + +from __future__ import annotations + +import ctypes +import json +from ctypes import wintypes +from datetime import datetime, timezone + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", 6: "ram_disk"} + + +def _volume_details() -> list[dict[str, int | str]]: + """Read mounted logical-volume capacity, label, and filesystem metadata only.""" + kernel32 = ctypes.windll.kernel32 + mask = kernel32.GetLogicalDrives() + if mask == 0: + raise ctypes.WinError() + volumes: list[dict[str, int | str]] = [] + for offset in range(26): + if not mask & (1 << offset): + continue + root = f"{chr(ord('A') + offset)}:\\" + available = ctypes.c_ulonglong() + total = ctypes.c_ulonglong() + free = ctypes.c_ulonglong() + if not kernel32.GetDiskFreeSpaceExW(root, ctypes.byref(available), ctypes.byref(total), ctypes.byref(free)): + continue + label = ctypes.create_unicode_buffer(261) + filesystem = ctypes.create_unicode_buffer(261) + serial = wintypes.DWORD() + maximum_component_length = wintypes.DWORD() + flags = wintypes.DWORD() + information_available = kernel32.GetVolumeInformationW( + root, label, len(label), ctypes.byref(serial), ctypes.byref(maximum_component_length), ctypes.byref(flags), filesystem, len(filesystem) + ) + volumes.append({ + "root": root, + "drive_type": _DRIVE_TYPES.get(kernel32.GetDriveTypeW(root), "unknown"), + "filesystem": filesystem.value if information_available else "unavailable", + "volume_name": label.value if information_available else "unavailable", + "total_bytes": total.value, + "free_bytes": free.value, + "available_bytes": available.value, + }) + return volumes + + +class VolumeDetailsCollector(CoreCollector): + """Capture detailed mounted logical-volume metadata without walking file contents.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the capability-free detailed volume artifact contract.""" + return CollectorMetadata( + id="core.storage.volume_details", name="Volume details", version="4.0.0", specialty=Specialty.STORAGE, + description="Exports mounted drive type, filesystem, label, and capacity metadata.", author="Logicytics", + supported_platforms=("win32",), default_profiles=("deep",), timeout_seconds=15, maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation and native volume API availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + _volume_details() + except OSError as error: + return ValidationResult(False, reasons=(f"volume API is unavailable: {error}",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Read detailed volume metadata and register a JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before volume-details collection") + context.report_progress("volume_details_started") + try: + volumes = _volume_details() + except OSError as error: + return CollectorResult(CollectorStatus.FAILED, "could not read detailed volume metadata", errors=(str(error),)) + output = context.workspace / "volume_details.json" + output.write_text(json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "volumes": volumes}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("volume_details_finished", volume_count=len(volumes), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("detailed volume metadata collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because Windows volume API calls are synchronous.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index ff6ef5cd..8a217c9e 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -33,6 +33,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.storage.logical_drives", "core.storage.physical_disks", "core.storage.mounted_volumes", + "core.storage.volume_details", "core.system.system_info", "core.system.system_details", "core.system.bios_info", From 620f89a899cdde0602b72c1a94a896b5b132437a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:00:27 +0300 Subject: [PATCH 034/316] feat: enrich BitLocker volume metadata --- core/encryption/bitlocker_volumes.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 5ca90118..8a6f1162 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -2,8 +2,13 @@ from __future__ import annotations +import ctypes +import getpass import json +import platform +import socket import subprocess +from datetime import datetime, timezone from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult @@ -16,6 +21,14 @@ def _is_access_denied(detail: str) -> bool: return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) +def _is_administrator() -> bool | None: + """Return the local administrator token state when Windows can report it.""" + try: + return bool(ctypes.windll.shell32.IsUserAnAdmin()) + except OSError: + return None + + class BitlockerVolumesCollector(CoreCollector): """Capture local BitLocker-volume metadata without changing encryption configuration.""" @@ -74,8 +87,16 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned invalid JSON", errors=(str(error),)) if not isinstance(volumes, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned an unexpected result") + report = { + "collected_at": datetime.now(timezone.utc).isoformat(), + "user": getpass.getuser(), + "is_administrator": _is_administrator(), + "hostname": socket.gethostname(), + "platform": platform.platform(), + "volumes": volumes, + } output = context.workspace / "bitlocker_volumes.json" - output.write_text(json.dumps(volumes, indent=2, sort_keys=True) + "\n", encoding="utf-8") + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") count = len(volumes) if isinstance(volumes, list) else 1 context.report_progress("bitlocker_volumes_finished", volume_count=count, bytes_written=artifact.size_bytes) From 8626b46543391944d8e7ce191c61e4169b21e7b2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:02:34 +0300 Subject: [PATCH 035/316] feat: add Security event log collector --- README.md | 1 + core/event_log/security_events.py | 68 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 70 insertions(+) create mode 100644 core/event_log/security_events.py diff --git a/README.md b/README.md index 3bb152ec..0ebeb4cd 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ rebuilt incrementally. `core.network.connection_processes`, `core.wireless.wifi_profiles`, `core.event_log.application_events`, + `core.event_log.security_events`, `core.system.installed_drivers`, and `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py new file mode 100644 index 00000000..3638b3a5 --- /dev/null +++ b/core/event_log/security_events.py @@ -0,0 +1,68 @@ +"""Export a bounded Windows Security event-log sample as CSV evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_MAX_EVENTS = 1_000 + + +def _is_access_denied(detail: str) -> bool: + """Recognize common access-denied wording emitted by PowerShell event queries.""" + normalized = detail.casefold() + return ( + "permission denied" in normalized + or "unauthorized" in normalized + or ("access" in normalized and "denied" in normalized) + ) + + +class SecurityEventsCollector(CoreCollector): + """Capture a bounded CSV sample of Security events without changing event logs.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated Security-event sample and output limit.""" + return CollectorMetadata( + id="core.event_log.security_events", name="Security event log", version="4.0.0", specialty=Specialty.EVENT_LOG, + description="Exports up to 1,000 local Windows Security events as CSV.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), + default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=8 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before querying events.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query a bounded Security-event sample and register its CSV output.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Security event-log collection") + context.report_progress("security_events_started", maximum_events=_MAX_EVENTS) + command = ( + f"Get-WinEvent -LogName Security -MaxEvents {_MAX_EVENTS} | " + "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | ConvertTo-Csv -NoTypeInformation" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=75) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Security event-log access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Security event-log query failed", errors=(detail,)) + output = context.workspace / "security_events.csv" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/csv") + event_count = max(0, len(completed.stdout.splitlines()) - 1) + context.report_progress("security_events_finished", event_count=event_count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Security event-log sample collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the event query process has already exited.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 8a217c9e..b0df71a6 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -48,6 +48,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.hardware.windows_features", "core.event_log.system_events", "core.event_log.application_events", + "core.event_log.security_events", "core.encryption.bitlocker_status", "core.encryption.bitlocker_volumes", }, From 41d9d228e61d2d6f52a003bce6182578f14833e4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:04:17 +0300 Subject: [PATCH 036/316] feat: add process memory collector --- README.md | 1 + core/process/process_memory.py | 71 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 73 insertions(+) create mode 100644 core/process/process_memory.py diff --git a/README.md b/README.md index 0ebeb4cd..d517101c 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ rebuilt incrementally. `core.system.session_snapshot`, `core.system.system_diagnostics`, `core.process.detailed_processes`, + `core.process.process_memory`, `core.network.active_connections`, `core.network.adapter_statistics`, `core.network.network_interfaces`, diff --git a/core/process/process_memory.py b/core/process/process_memory.py new file mode 100644 index 00000000..26d6cfc9 --- /dev/null +++ b/core/process/process_memory.py @@ -0,0 +1,71 @@ +"""Export bounded per-process memory counters as read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ProcessMemoryCollector(CoreCollector): + """Capture per-process aggregate memory counters without reading memory contents.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated process-memory JSON artifact contract.""" + return CollectorMetadata( + id="core.process.process_memory", name="Process memory", version="4.0.0", specialty=Specialty.PROCESS, + description="Exports aggregate working-set, private, and virtual memory counters for local processes.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("process_metadata",), default_profiles=("deep",), timeout_seconds=60, + maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query aggregate process memory counters and register a JSON artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before process-memory collection") + context.report_progress("process_memory_started") + command = ( + "$ErrorActionPreference = 'Continue'; Get-Process | " + "Select-Object Id, ProcessName, WorkingSet64, PrivateMemorySize64, VirtualMemorySize64, HandleCount, CPU, StartTime | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=55) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "process-memory access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "process-memory query failed", errors=(detail,)) + try: + processes = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "process-memory query returned invalid JSON", errors=(str(error),)) + if not isinstance(processes, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "process-memory query returned an unexpected result") + output = context.workspace / "process_memory.json" + output.write_text(json.dumps(processes, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(processes) if isinstance(processes, list) else 1 + context.report_progress("process_memory_finished", process_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("process memory collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index b0df71a6..dc535766 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -30,6 +30,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.memory.memory_snapshot", "core.process.running_processes", "core.process.detailed_processes", + "core.process.process_memory", "core.storage.logical_drives", "core.storage.physical_disks", "core.storage.mounted_volumes", From 07d360526802bd9ed3c6942f7a7e004f764fb219 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:05:51 +0300 Subject: [PATCH 037/316] feat: add system drive tree collector --- README.md | 1 + core/filesystem/system_drive_tree.py | 84 ++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 86 insertions(+) create mode 100644 core/filesystem/system_drive_tree.py diff --git a/README.md b/README.md index d517101c..fcbb5588 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ rebuilt incrementally. `core.system.system_diagnostics`, `core.process.detailed_processes`, `core.process.process_memory`, + `core.filesystem.system_drive_tree`, `core.network.active_connections`, `core.network.adapter_statistics`, `core.network.network_interfaces`, diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py new file mode 100644 index 00000000..941b0b23 --- /dev/null +++ b/core/filesystem/system_drive_tree.py @@ -0,0 +1,84 @@ +"""Create a bounded recursive tree of the Windows system drive without reading file contents.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_DEFAULT_MAX_ENTRIES = 5_000 +_DEFAULT_MAX_DEPTH = 12 +_HARD_MAX_ENTRIES = 50_000 +_HARD_MAX_DEPTH = 32 + + +def _bounded_setting(settings: object, key: str, default: int, maximum: int) -> int: + """Return one integer traversal setting constrained to a safe positive range.""" + value = settings.get(key, default) if isinstance(settings, dict) else default + return value if isinstance(value, int) and 1 <= value <= maximum else default + + +class SystemDriveTreeCollector(CoreCollector): + """Capture a bounded recursive system-drive tree without reading file contents.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the filesystem-read, bounded system-drive tree artifact contract.""" + return CollectorMetadata( + id="core.filesystem.system_drive_tree", name="System drive tree", version="4.0.0", specialty=Specialty.FILESYSTEM, + description="Exports a configurable bounded recursive directory tree for the Windows system drive.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), + sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=120, + maximum_output_bytes=8 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and system-drive availability before traversal.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + system_drive = Path(os.environ.get("SystemDrive", "C:") + "\\") + if not system_drive.is_dir(): + return ValidationResult(False, reasons=(f"system drive is unavailable: {system_drive}",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Walk a bounded tree and register the metadata-only text evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive tree collection") + maximum_entries = _bounded_setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) + maximum_depth = _bounded_setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) + root = Path(os.environ.get("SystemDrive", "C:") + "\\") + lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}"] + entries = 0 + skipped = 0 + truncated = False + context.report_progress("system_drive_tree_started", max_entries=maximum_entries, max_depth=maximum_depth) + for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, onerror=lambda _error: None): + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive tree collection") + relative = Path(current).relative_to(root) + depth = len(relative.parts) + if depth >= maximum_depth: + skipped += len(directories) + directories[:] = [] + for name, marker in [(name, "/") for name in directories] + [(name, "") for name in filenames]: + if entries >= maximum_entries: + truncated = True + break + lines.append(f"{relative / name}{marker}") + entries += 1 + if truncated: + break + if truncated: + lines.append("# truncated=true") + output = context.workspace / "system_drive_tree.txt" + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + context.report_progress("system_drive_tree_finished", entry_count=entries, skipped_directories=skipped, truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + summary = "system-drive tree collected" if not truncated else "system-drive tree collected with configured entry limit" + return CollectorResult.succeeded(summary, (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because traversal holds no persistent resources.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index dc535766..576a8b6d 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -50,6 +50,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.event_log.system_events", "core.event_log.application_events", "core.event_log.security_events", + "core.filesystem.system_drive_tree", "core.encryption.bitlocker_status", "core.encryption.bitlocker_volumes", }, From 04676c185d0cda7ed7f3d8346ff6e3781859bfde Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:07:31 +0300 Subject: [PATCH 038/316] feat: add threaded system drive listing collector --- README.md | 1 + core/filesystem/system_drive_listing.py | 120 ++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 122 insertions(+) create mode 100644 core/filesystem/system_drive_listing.py diff --git a/README.md b/README.md index fcbb5588..a13790bb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ rebuilt incrementally. `core.process.detailed_processes`, `core.process.process_memory`, `core.filesystem.system_drive_tree`, + `core.filesystem.system_drive_listing`, `core.network.active_connections`, `core.network.adapter_statistics`, `core.network.network_interfaces`, diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py new file mode 100644 index 00000000..c53cf419 --- /dev/null +++ b/core/filesystem/system_drive_listing.py @@ -0,0 +1,120 @@ +"""Create a bounded threaded recursive listing of the Windows system drive.""" + +from __future__ import annotations + +import os +from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_DEFAULT_MAX_ENTRIES = 10_000 +_DEFAULT_MAX_DEPTH = 16 +_DEFAULT_WORKERS = 4 +_HARD_MAX_ENTRIES = 50_000 +_HARD_MAX_DEPTH = 32 +_HARD_MAX_WORKERS = 8 + + +def _setting(settings: object, key: str, default: int, maximum: int) -> int: + """Return one positive integer setting constrained to its documented maximum.""" + value = settings.get(key, default) if isinstance(settings, dict) else default + return value if isinstance(value, int) and 1 <= value <= maximum else default + + +def _scan_directory(directory: Path) -> tuple[list[str], list[Path]]: + """Return metadata-only entries and non-link child directories for one path.""" + entries: list[str] = [] + children: list[Path] = [] + try: + with os.scandir(directory) as scan: + for entry in scan: + try: + is_directory = entry.is_dir(follow_symlinks=False) + except OSError: + continue + entries.append(f"{entry.path}{'/' if is_directory else ''}") + if is_directory: + children.append(Path(entry.path)) + except OSError: + return [], [] + return sorted(entries), sorted(children) + + +class SystemDriveListingCollector(CoreCollector): + """Capture a bounded threaded recursive directory listing without reading file contents.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the filesystem-read, threaded system-drive listing contract.""" + return CollectorMetadata( + id="core.filesystem.system_drive_listing", name="System drive listing", version="4.0.0", specialty=Specialty.FILESYSTEM, + description="Exports a configurable, bounded threaded recursive listing of the Windows system drive.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), + sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=180, + maximum_output_bytes=8 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and system-drive availability before traversal.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + root = Path(os.environ.get("SystemDrive", "C:") + "\\") + if not root.is_dir(): + return ValidationResult(False, reasons=(f"system drive is unavailable: {root}",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Traverse the system drive in bounded parallel batches and register text evidence.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive listing collection") + maximum_entries = _setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) + maximum_depth = _setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) + workers = _setting(context.settings, "workers", _DEFAULT_WORKERS, _HARD_MAX_WORKERS) + root = Path(os.environ.get("SystemDrive", "C:") + "\\") + lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}", f"# workers={workers}"] + pending: dict[Future[tuple[list[str], list[Path]]], tuple[Path, int]] = {} + entries = 0 + truncated = False + context.report_progress("system_drive_listing_started", max_entries=maximum_entries, max_depth=maximum_depth, workers=workers) + with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="LogicyticsList") as executor: + pending[executor.submit(_scan_directory, root)] = (root, 0) + while pending and entries < maximum_entries: + done, _ = wait(pending, return_when=FIRST_COMPLETED) + for future in done: + directory, depth = pending.pop(future) + names, children = future.result() + for name in names: + if entries >= maximum_entries: + truncated = True + break + lines.append(str(Path(name).relative_to(root))) + entries += 1 + if truncated: + break + if depth < maximum_depth: + for child in children: + if len(pending) >= maximum_entries: + truncated = True + break + pending[executor.submit(_scan_directory, child)] = (child, depth + 1) + if truncated: + break + if context.is_cancelled: + for future in pending: + future.cancel() + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive listing collection") + if truncated: + break + if truncated: + lines.append("# truncated=true") + output = context.workspace / "system_drive_listing.txt" + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + context.report_progress("system_drive_listing_finished", entry_count=entries, truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + summary = "threaded system-drive listing collected" if not truncated else "threaded system-drive listing collected with configured entry limit" + return CollectorResult.succeeded(summary, (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the scoped thread pool has already shut down.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 576a8b6d..c5a09977 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -51,6 +51,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.event_log.application_events", "core.event_log.security_events", "core.filesystem.system_drive_tree", + "core.filesystem.system_drive_listing", "core.encryption.bitlocker_status", "core.encryption.bitlocker_volumes", }, From 89ca2b80a4d747c07c5fb3ed401613d66c5089ee Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:08:44 +0300 Subject: [PATCH 039/316] feat: add used memory metrics --- core/memory/memory_snapshot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index 5eca1970..610b3482 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -79,14 +79,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: "physical_memory": { "total_bytes": status.ullTotalPhys, "available_bytes": status.ullAvailPhys, + "used_bytes": status.ullTotalPhys - status.ullAvailPhys, }, "page_file": { "total_bytes": status.ullTotalPageFile, "available_bytes": status.ullAvailPageFile, + "used_bytes": status.ullTotalPageFile - status.ullAvailPageFile, }, "virtual_memory": { "total_bytes": status.ullTotalVirtual, "available_bytes": status.ullAvailVirtual, + "used_bytes": status.ullTotalVirtual - status.ullAvailVirtual, }, } output = context.workspace / "memory_snapshot.json" From 208b746abb0a18b9a727c6ceebd37c46cb1803a9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:10:24 +0300 Subject: [PATCH 040/316] feat: add bandwidth sample collector --- README.md | 1 + core/network/bandwidth_sample.py | 101 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 103 insertions(+) create mode 100644 core/network/bandwidth_sample.py diff --git a/README.md b/README.md index a13790bb..d3bc6a72 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ rebuilt incrementally. `core.filesystem.system_drive_listing`, `core.network.active_connections`, `core.network.adapter_statistics`, + `core.network.bandwidth_sample`, `core.network.network_interfaces`, `core.network.connection_processes`, `core.wireless.wifi_profiles`, diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py new file mode 100644 index 00000000..6e672ce1 --- /dev/null +++ b/core/network/bandwidth_sample.py @@ -0,0 +1,101 @@ +"""Measure local adapter bandwidth from bounded read-only counter samples.""" + +from __future__ import annotations + +import json +import subprocess +import time +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_DEFAULT_SAMPLES = 3 +_DEFAULT_INTERVAL_SECONDS = 1 + + +def _setting(settings: object, key: str, default: int, maximum: int) -> int: + """Return one bounded positive integer collector setting.""" + value = settings.get(key, default) if isinstance(settings, dict) else default + return value if isinstance(value, int) and 1 <= value <= maximum else default + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class BandwidthSampleCollector(CoreCollector): + """Measure receive/send byte rates locally without generating network traffic.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated bounded bandwidth-sampling artifact contract.""" + return CollectorMetadata( + id="core.network.bandwidth_sample", name="Network bandwidth sample", version="4.0.0", specialty=Specialty.NETWORK, + description="Calculates local per-interface average and peak bandwidth from adapter counter samples.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_identifiers",), + default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before sampling.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Take bounded counter samples and register calculated rate evidence as JSON.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before bandwidth sampling") + samples = _setting(context.settings, "sample_count", _DEFAULT_SAMPLES, 10) + interval = _setting(context.settings, "interval_seconds", _DEFAULT_INTERVAL_SECONDS, 5) + command = "Get-NetAdapterStatistics | Select-Object Name, ReceivedBytes, SentBytes | ConvertTo-Json -Depth 3" + observations: list[dict[str, dict[str, int]]] = [] + context.report_progress("bandwidth_sample_started", sample_count=samples, interval_seconds=interval) + for index in range(samples): + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during bandwidth sampling") + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=20) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "bandwidth-sample access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query failed", errors=(detail,)) + try: + raw = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned invalid JSON", errors=(str(error),)) + records = raw if isinstance(raw, list) else [raw] + if not all(isinstance(record, dict) for record in records): + return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned an unexpected result") + observations.append({str(record.get("Name", "unavailable")): {"received": int(record.get("ReceivedBytes", 0)), "sent": int(record.get("SentBytes", 0))} for record in records}) + if index + 1 < samples: + time.sleep(interval) + rates: dict[str, dict[str, float]] = {} + for previous, current in zip(observations, observations[1:]): + for name, counters in current.items(): + if name not in previous: + continue + receive = max(0, counters["received"] - previous[name]["received"]) / interval + sent = max(0, counters["sent"] - previous[name]["sent"]) / interval + values = rates.setdefault(name, {"receive_average_bytes_per_second": 0.0, "receive_peak_bytes_per_second": 0.0, "send_average_bytes_per_second": 0.0, "send_peak_bytes_per_second": 0.0, "sample_intervals": 0.0}) + values["sample_intervals"] += 1 + values["receive_average_bytes_per_second"] += receive + values["send_average_bytes_per_second"] += sent + values["receive_peak_bytes_per_second"] = max(values["receive_peak_bytes_per_second"], receive) + values["send_peak_bytes_per_second"] = max(values["send_peak_bytes_per_second"], sent) + for values in rates.values(): + values["receive_average_bytes_per_second"] /= values["sample_intervals"] + values["send_average_bytes_per_second"] /= values["sample_intervals"] + output = context.workspace / "bandwidth_sample.json" + output.write_text(json.dumps({"sample_count": samples, "interval_seconds": interval, "interfaces": rates}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("bandwidth_sample_finished", interface_count=len(rates), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("network bandwidth sample collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because sampling subprocesses have already exited.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index c5a09977..0336e747 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -23,6 +23,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.active_connections", "core.network.connection_processes", "core.network.adapter_statistics", + "core.network.bandwidth_sample", "core.network.network_interfaces", "core.network.network_adapters", "core.network.arp_cache", From ec437e6c4bc18697bde3259c23752781288cc826 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:13:31 +0300 Subject: [PATCH 041/316] feat: add Bluetooth address collector --- README.md | 1 + core/bluetooth/bluetooth_addresses.py | 73 +++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 75 insertions(+) create mode 100644 core/bluetooth/bluetooth_addresses.py diff --git a/README.md b/README.md index d3bc6a72..9c1320b7 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ rebuilt incrementally. `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. + Deep-profile inventory also includes `core.bluetooth.bluetooth_addresses`. Deep-profile collection also includes `core.system.system_details`, `core.system.bios_info`, `core.system.operating_system`, diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py new file mode 100644 index 00000000..48a55308 --- /dev/null +++ b/core/bluetooth/bluetooth_addresses.py @@ -0,0 +1,73 @@ +"""Export paired Bluetooth names and address-like PnP identifiers as bounded JSON.""" + +from __future__ import annotations + +import json +import re +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +def _is_access_denied(detail: str) -> bool: + """Recognize common access-denied wording from PowerShell PnP queries.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +def _format_address(instance_id: object) -> str | None: + """Extract one conventional colon-separated address from a PnP instance identifier.""" + match = re.search(r"(? CollectorMetadata: + """Declare the subprocess-gated Bluetooth-address JSON artifact contract.""" + return CollectorMetadata( + id="core.bluetooth.bluetooth_addresses", name="Bluetooth addresses", version="4.0.0", specialty=Specialty.BLUETOOTH, + description="Exports paired Bluetooth friendly names and address-like identifiers from PnP data.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("wireless_identifiers",), + default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query PnP data and register Bluetooth names and extracted addresses as JSON.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-address collection") + context.report_progress("bluetooth_addresses_started") + command = "Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Present | ConvertTo-Json -Depth 2" + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=25) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Bluetooth PnP access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query failed", errors=(detail,)) + try: + raw = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned invalid JSON", errors=(str(error),)) + devices = raw if isinstance(raw, list) else [raw] + if not all(isinstance(device, dict) for device in devices): + return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned an unexpected result") + report = [{"friendly_name": device.get("FriendlyName"), "instance_id": device.get("InstanceId"), "address": _format_address(device.get("InstanceId")), "present": device.get("Present")} for device in devices] + output = context.workspace / "bluetooth_addresses.json" + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("bluetooth_addresses_finished", device_count=len(report), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Bluetooth names and address identifiers collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 0336e747..cf8bbf9a 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -19,6 +19,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: self.assertEqual( { "core.bluetooth.paired_devices", + "core.bluetooth.bluetooth_addresses", "core.network.network_identity", "core.network.active_connections", "core.network.connection_processes", From 2c9581487f2706ba2a03c78e4ceae0f90f1ad233 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:14:51 +0300 Subject: [PATCH 042/316] feat: add USB registry timestamps --- core/usb/usb_storage_inventory.py | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/core/usb/usb_storage_inventory.py b/core/usb/usb_storage_inventory.py index 93b666bd..c7279b01 100644 --- a/core/usb/usb_storage_inventory.py +++ b/core/usb/usb_storage_inventory.py @@ -2,9 +2,11 @@ from __future__ import annotations +import ctypes import json import winreg -from datetime import datetime, timezone +from ctypes import wintypes +from datetime import datetime, timedelta, timezone from logicytics import ( Capability, @@ -19,9 +21,21 @@ _USBSTOR_PATH = r"SYSTEM\CurrentControlSet\Enum\USBSTOR" -def _enumerate_usb_storage() -> list[dict[str, str]]: +def _registry_last_write(key: winreg.HKEYType) -> str | None: + """Return one registry key's last-write timestamp in UTC when Windows reports it.""" + timestamp = wintypes.FILETIME() + result = ctypes.windll.advapi32.RegQueryInfoKeyW( + wintypes.HKEY(key.handle), None, None, None, None, None, None, None, None, None, None, ctypes.byref(timestamp) + ) + if result != 0: + return None + ticks = (timestamp.dwHighDateTime << 32) | timestamp.dwLowDateTime + return (datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(microseconds=ticks // 10)).isoformat() + + +def _enumerate_usb_storage() -> list[dict[str, str | None]]: """Return USBSTOR device class, instance ID, and friendly name when readable.""" - devices: list[dict[str, str]] = [] + devices: list[dict[str, str | None]] = [] with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, _USBSTOR_PATH) as root: class_index = 0 while True: @@ -44,13 +58,15 @@ def _enumerate_usb_storage() -> list[dict[str, str]]: try: with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, instance_path) as instance_key: friendly_name = str(winreg.QueryValueEx(instance_key, "FriendlyName")[0]) + last_write = _registry_last_write(instance_key) except OSError: - pass + last_write = None devices.append( { "device_class": device_class, "instance_id": instance_id, "friendly_name": friendly_name, + "last_write": last_write, } ) return devices @@ -67,7 +83,7 @@ def metadata(cls) -> CollectorMetadata: name="USB storage inventory", version="4.0.0", specialty=Specialty.USB, - description="Reads USB storage device class, instance ID, and friendly name from USBSTOR.", + description="Reads USB storage device class, instance ID, friendly name, and last-write time from USBSTOR.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), From e983c2fcd7e76ac2e0e8568db6a2e585d38136a4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:17:59 +0300 Subject: [PATCH 043/316] feat: enforce collector lifecycle annotations --- logicytics/discovery.py | 11 ++++++++++- tests/test_core_functionality.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index aebe31f8..8126ddfd 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -150,6 +150,12 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat if unknown: candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") required = {"metadata", "validate", "collect", "cleanup"} + expected_returns = { + "metadata": "CollectorMetadata", + "validate": "ValidationResult", + "collect": "CollectorResult", + "cleanup": "None", + } missing = sorted(required - set(methods)) if missing: candidate.static_errors.append(f"missing required collector methods: {', '.join(missing)}") @@ -158,6 +164,8 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat candidate.static_errors.append(f"{name} requires a docstring") if method.returns is None: candidate.static_errors.append(f"{name} requires a return type annotation") + elif name in expected_returns and ast.unparse(method.returns).rsplit(".", 1)[-1] != expected_returns[name]: + candidate.static_errors.append(f"{name} must return {expected_returns[name]}") parameters = method.args.args expected_parameter_count = 1 if name == "metadata" else 2 if len(parameters) != expected_parameter_count: @@ -171,7 +179,8 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat candidate.static_errors.append("metadata must be a classmethod") else: context_parameter = parameters[1] - if context_parameter.arg != "context" or context_parameter.annotation is None: + annotation = ast.unparse(context_parameter.annotation).rsplit(".", 1)[-1] if context_parameter.annotation else None + if context_parameter.arg != "context" or annotation != "CollectorContext": candidate.static_errors.append(f"{name} must accept an annotated context parameter") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 2fd24371..fc9d6a18 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -123,6 +123,21 @@ def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: [candidate.metadata.id for candidate in plan.collectors], ) + def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: + """Lifecycle annotations must match the strict collector contract exactly.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace("def collect(self, context: CollectorContext) -> CollectorResult:", "def collect(self, context: CollectorContext) -> ValidationResult:"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) + def test_capability_gate_requires_explicit_approval(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 2956177b5813f99ad0cbc7556810589dea630232 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:19:34 +0300 Subject: [PATCH 044/316] feat: add installed updates collector --- README.md | 1 + core/system/installed_updates.py | 66 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 68 insertions(+) create mode 100644 core/system/installed_updates.py diff --git a/README.md b/README.md index 9c1320b7..e4e4487c 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ rebuilt incrementally. `core.event_log.application_events`, `core.event_log.security_events`, `core.system.installed_drivers`, and + `core.system.installed_updates`, `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and `core.storage.physical_disks`, and `core.storage.mounted_volumes`. diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py new file mode 100644 index 00000000..46a3e3a5 --- /dev/null +++ b/core/system/installed_updates.py @@ -0,0 +1,66 @@ +"""Export installed Windows hotfix metadata as bounded, read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class InstalledUpdatesCollector(CoreCollector): + """Capture read-only local Windows hotfix metadata through the update provider.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated installed-updates JSON artifact contract.""" + return CollectorMetadata( + id="core.system.installed_updates", name="Installed Windows updates", version="4.0.0", specialty=Specialty.SYSTEM, + description="Exports local installed hotfix identifiers, descriptions, and install dates.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query installed hotfixes and register their bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before installed-update collection") + context.report_progress("installed_updates_started") + command = "Get-HotFix | Select-Object HotFixID, Description, InstalledBy, InstalledOn | ConvertTo-Json -Depth 3" + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "installed-update access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "installed-update query failed", errors=(detail,)) + try: + updates = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "installed-update query returned invalid JSON", errors=(str(error),)) + if not isinstance(updates, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "installed-update query returned an unexpected result") + output = context.workspace / "installed_updates.json" + output.write_text(json.dumps(updates, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(updates) if isinstance(updates, list) else 1 + context.report_progress("installed_updates_finished", update_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("installed Windows updates collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index cf8bbf9a..37a5d990 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -45,6 +45,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.session_snapshot", "core.system.system_diagnostics", "core.system.installed_drivers", + "core.system.installed_updates", "core.system.group_policy", "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", From ad578976dc8a5b8aabd6624faf94c7a63a822be3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:21:53 +0300 Subject: [PATCH 045/316] feat: add installed applications collector --- README.md | 1 + core/registry/installed_applications.py | 99 +++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 101 insertions(+) create mode 100644 core/registry/installed_applications.py diff --git a/README.md b/README.md index e4e4487c..23e564ad 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ rebuilt incrementally. `core.system.system_diagnostics`, `core.process.detailed_processes`, `core.process.process_memory`, + `core.registry.installed_applications`, `core.filesystem.system_drive_tree`, `core.filesystem.system_drive_listing`, `core.network.active_connections`, diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py new file mode 100644 index 00000000..ad4cf44f --- /dev/null +++ b/core/registry/installed_applications.py @@ -0,0 +1,99 @@ +"""Export installed Windows application metadata from read-only uninstall registry keys.""" + +from __future__ import annotations + +import json +import winreg +from datetime import datetime, timezone + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_UNINSTALL_PATHS = ( + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall", +) +_MAX_APPLICATIONS = 5_000 + + +def _registry_value(key: winreg.HKEYType, name: str) -> str | None: + """Return a string registry value when present without exposing registry errors.""" + try: + value, _ = winreg.QueryValueEx(key, name) + except OSError: + return None + return str(value) if value is not None else None + + +def _installed_applications() -> list[dict[str, str | None]]: + """Enumerate uninstall metadata from 64-bit and WOW6432Node registry views.""" + applications: list[dict[str, str | None]] = [] + seen: set[tuple[str, str]] = set() + for path in _UNINSTALL_PATHS: + try: + root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) + except OSError: + continue + with root: + index = 0 + while len(applications) < _MAX_APPLICATIONS: + try: + key_name = winreg.EnumKey(root, index) + except OSError: + break + index += 1 + try: + with winreg.OpenKey(root, key_name) as key: + display_name = _registry_value(key, "DisplayName") + if not display_name: + continue + version = _registry_value(key, "DisplayVersion") or "" + identity = (display_name, version) + if identity in seen: + continue + seen.add(identity) + applications.append({"display_name": display_name, "display_version": version or None, "publisher": _registry_value(key, "Publisher"), "install_date": _registry_value(key, "InstallDate"), "install_location": _registry_value(key, "InstallLocation"), "uninstall_key": key_name}) + except OSError: + continue + return sorted(applications, key=lambda item: (item["display_name"] or "").casefold()) + + +class InstalledApplicationsCollector(CoreCollector): + """Capture read-only installed-application metadata from Windows registry views.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the registry-read, bounded installed-application artifact contract.""" + return CollectorMetadata( + id="core.registry.installed_applications", name="Installed applications", version="4.0.0", specialty=Specialty.REGISTRY, + description="Exports installed application names, versions, publishers, and install metadata from uninstall keys.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation and whether at least one uninstall registry view is readable.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + for path in _UNINSTALL_PATHS: + try: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path): + return ValidationResult(True) + except OSError: + continue + return ValidationResult(False, reasons=("Windows uninstall registry keys are unavailable",)) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Read installed application metadata and register the bounded JSON artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before installed-application collection") + context.report_progress("installed_applications_started") + applications = _installed_applications() + output = context.workspace / "installed_applications.json" + output.write_text(json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "applications": applications}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("installed_applications_finished", application_count=len(applications), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("installed applications collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because all registry handles are scoped and closed.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 37a5d990..de91d458 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -33,6 +33,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.running_processes", "core.process.detailed_processes", "core.process.process_memory", + "core.registry.installed_applications", "core.storage.logical_drives", "core.storage.physical_disks", "core.storage.mounted_volumes", From a65b717caf1c4118edb23fc63ba01fbaea7f8b52 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:23:27 +0300 Subject: [PATCH 046/316] feat: add Windows services collector --- README.md | 1 + core/system/windows_services.py | 69 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 71 insertions(+) create mode 100644 core/system/windows_services.py diff --git a/README.md b/README.md index 23e564ad..9b5928d6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ rebuilt incrementally. `core.event_log.security_events`, `core.system.installed_drivers`, and `core.system.installed_updates`, + `core.system.windows_services`, `core.system.group_policy`, `core.event_log.system_events`, and `core.network.arp_cache`, `core.network.routing_table`, and `core.storage.physical_disks`, and `core.storage.mounted_volumes`. diff --git a/core/system/windows_services.py b/core/system/windows_services.py new file mode 100644 index 00000000..adc0d365 --- /dev/null +++ b/core/system/windows_services.py @@ -0,0 +1,69 @@ +"""Export local Windows service metadata as bounded, read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class WindowsServicesCollector(CoreCollector): + """Capture read-only service names, states, startup modes, and paths through CIM.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated Windows-services JSON artifact contract.""" + return CollectorMetadata( + id="core.system.windows_services", name="Windows services", version="4.0.0", specialty=Specialty.SYSTEM, + description="Exports local Windows service names, states, start modes, accounts, and executable paths.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query service CIM metadata and register a bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Windows-services collection") + context.report_progress("windows_services_started") + command = ( + "Get-CimInstance -ClassName Win32_Service | " + "Select-Object Name, DisplayName, State, StartMode, StartName, PathName, ProcessId | ConvertTo-Json -Depth 3" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=55) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Windows-service access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Windows-service query failed", errors=(detail,)) + try: + services = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned invalid JSON", errors=(str(error),)) + if not isinstance(services, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned an unexpected result") + output = context.workspace / "windows_services.json" + output.write_text(json.dumps(services, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(services) if isinstance(services, list) else 1 + context.report_progress("windows_services_finished", service_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Windows services collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index de91d458..f4c2a248 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -47,6 +47,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.system_diagnostics", "core.system.installed_drivers", "core.system.installed_updates", + "core.system.windows_services", "core.system.group_policy", "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", From 66a37db74567a1c2ba6ce220950b4519acf164d2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:24:56 +0300 Subject: [PATCH 047/316] feat: add startup applications collector --- README.md | 1 + core/registry/startup_applications.py | 72 +++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 74 insertions(+) create mode 100644 core/registry/startup_applications.py diff --git a/README.md b/README.md index 9b5928d6..3b1da066 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ rebuilt incrementally. `core.process.detailed_processes`, `core.process.process_memory`, `core.registry.installed_applications`, + `core.registry.startup_applications`, `core.filesystem.system_drive_tree`, `core.filesystem.system_drive_listing`, `core.network.active_connections`, diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py new file mode 100644 index 00000000..04ead920 --- /dev/null +++ b/core/registry/startup_applications.py @@ -0,0 +1,72 @@ +"""Export standard Windows startup Run-key entries as bounded read-only JSON evidence.""" + +from __future__ import annotations + +import json +import winreg +from datetime import datetime, timezone + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_RUN_PATHS = ( + ("HKEY_CURRENT_USER", winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run"), + ("HKEY_CURRENT_USER", winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\RunOnce"), + ("HKEY_LOCAL_MACHINE", winreg.HKEY_LOCAL_MACHINE, r"Software\Microsoft\Windows\CurrentVersion\Run"), + ("HKEY_LOCAL_MACHINE", winreg.HKEY_LOCAL_MACHINE, r"Software\Microsoft\Windows\CurrentVersion\RunOnce"), +) + + +def _startup_entries() -> list[dict[str, str]]: + """Enumerate values from conventional per-user and machine startup Run keys.""" + entries: list[dict[str, str]] = [] + for hive_name, hive, path in _RUN_PATHS: + try: + key = winreg.OpenKey(hive, path) + except OSError: + continue + with key: + index = 0 + while True: + try: + name, value, _ = winreg.EnumValue(key, index) + except OSError: + break + index += 1 + entries.append({"hive": hive_name, "path": path, "name": name, "command": str(value)}) + return sorted(entries, key=lambda item: (item["hive"], item["path"], item["name"].casefold())) + + +class StartupApplicationsCollector(CoreCollector): + """Capture startup Run-key commands without launching or modifying any application.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the registry-read startup-application artifact contract.""" + return CollectorMetadata( + id="core.registry.startup_applications", name="Startup applications", version="4.0.0", specialty=Specialty.REGISTRY, + description="Exports standard user and machine Run/RunOnce startup registry entries.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation before reading potentially available startup key paths.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Read startup metadata and register a bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before startup-application collection") + context.report_progress("startup_applications_started") + entries = _startup_entries() + output = context.workspace / "startup_applications.json" + output.write_text(json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "entries": entries}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("startup_applications_finished", entry_count=len(entries), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("startup applications collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because all registry handles are scoped and closed.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index f4c2a248..9a5381f0 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -34,6 +34,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.detailed_processes", "core.process.process_memory", "core.registry.installed_applications", + "core.registry.startup_applications", "core.storage.logical_drives", "core.storage.physical_disks", "core.storage.mounted_volumes", From 279e98c8c5976b03af79c7ef53a7b6fc650f0811 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:26:32 +0300 Subject: [PATCH 048/316] feat: add battery status collector --- README.md | 1 + core/hardware/battery_status.py | 70 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 3 files changed, 72 insertions(+) create mode 100644 core/hardware/battery_status.py diff --git a/README.md b/README.md index 3b1da066..f3d58934 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ rebuilt incrementally. `core.process.running_processes` and `core.network.network_identity`, plus `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and + `core.hardware.battery_status`, `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. Deep-profile inventory also includes `core.bluetooth.bluetooth_addresses`. Deep-profile collection also includes `core.system.system_details`, diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py new file mode 100644 index 00000000..47632211 --- /dev/null +++ b/core/hardware/battery_status.py @@ -0,0 +1,70 @@ +"""Export local Windows battery status as bounded read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class BatteryStatusCollector(CoreCollector): + """Capture read-only battery capacity and charge state through Windows CIM.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated battery-status JSON artifact contract.""" + return CollectorMetadata( + id="core.hardware.battery_status", name="Battery status", version="4.0.0", specialty=Specialty.HARDWARE, + description="Exports local battery name, status, charge, capacity, and estimated runtime metadata.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query battery CIM metadata and register a bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before battery-status collection") + context.report_progress("battery_status_started") + command = ( + "Get-CimInstance -ClassName Win32_Battery | " + "Select-Object Name, BatteryStatus, EstimatedChargeRemaining, EstimatedRunTime, DesignCapacity, FullChargeCapacity, Status | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "battery-status access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "battery-status query failed", errors=(detail,)) + try: + batteries = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "battery-status query returned invalid JSON", errors=(str(error),)) + if not isinstance(batteries, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "battery-status query returned an unexpected result") + output = context.workspace / "battery_status.json" + output.write_text(json.dumps(batteries, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(batteries) if isinstance(batteries, list) else 1 + context.report_progress("battery_status_finished", battery_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("battery status collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 9a5381f0..b834da21 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -53,6 +53,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", "core.hardware.windows_features", + "core.hardware.battery_status", "core.event_log.system_events", "core.event_log.application_events", "core.event_log.security_events", From fe84328971a3bf82ffca217784187163a122ff62 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:28:31 +0300 Subject: [PATCH 049/316] feat: add DNS cache collector --- core/network/dns_cache.py | 57 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 58 insertions(+) create mode 100644 core/network/dns_cache.py diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py new file mode 100644 index 00000000..bdfecf3d --- /dev/null +++ b/core/network/dns_cache.py @@ -0,0 +1,57 @@ +"""Export the local Windows DNS resolver cache as bounded sensitive text evidence.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from ipconfig output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class DnsCacheCollector(CoreCollector): + """Capture the local DNS resolver cache without modifying resolver state or sending traffic.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated sensitive DNS-cache artifact contract.""" + return CollectorMetadata( + id="core.network.dns_cache", name="DNS resolver cache", version="4.0.0", specialty=Specialty.NETWORK, + description="Exports local DNS resolver cache records through read-only ipconfig output.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("dns_history",), + default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=4 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and ipconfig availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("ipconfig") is None: + return ValidationResult(False, reasons=("ipconfig is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run the read-only DNS-cache query and register its bounded text artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before DNS-cache collection") + context.report_progress("dns_cache_started") + completed = subprocess.run(["ipconfig", "/displaydns"], capture_output=True, check=False, text=True, timeout=25) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "DNS-cache access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "DNS-cache query failed", errors=(detail,)) + output = context.workspace / "dns_cache.txt" + output.write_text(completed.stdout, encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + context.report_progress("dns_cache_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("DNS resolver cache collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because ipconfig exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index b834da21..3ff84d54 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -23,6 +23,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.network_identity", "core.network.active_connections", "core.network.connection_processes", + "core.network.dns_cache", "core.network.adapter_statistics", "core.network.bandwidth_sample", "core.network.network_interfaces", From c2f1ae22e6298fd02ba6043502c0c46b9f8ea4b0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:31:16 +0300 Subject: [PATCH 050/316] feat: add firewall profiles collector --- core/network/firewall_profiles.py | 70 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 71 insertions(+) create mode 100644 core/network/firewall_profiles.py diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py new file mode 100644 index 00000000..85b266b0 --- /dev/null +++ b/core/network/firewall_profiles.py @@ -0,0 +1,70 @@ +"""Export local Windows firewall profile settings as bounded read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class FirewallProfilesCollector(CoreCollector): + """Capture read-only local firewall profile configuration without changing firewall state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated firewall-profile JSON artifact contract.""" + return CollectorMetadata( + id="core.network.firewall_profiles", name="Firewall profiles", version="4.0.0", specialty=Specialty.NETWORK, + description="Exports local Domain, Private, and Public Windows firewall profile settings.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query firewall profiles and register their bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before firewall-profile collection") + context.report_progress("firewall_profiles_started") + command = ( + "Get-NetFirewallProfile | " + "Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, NotifyOnListen, AllowInboundRules, AllowLocalFirewallRules, AllowLocalIPsecRules, LogFileName | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "firewall-profile access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "firewall-profile query failed", errors=(detail,)) + try: + profiles = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned invalid JSON", errors=(str(error),)) + if not isinstance(profiles, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned an unexpected result") + output = context.workspace / "firewall_profiles.json" + output.write_text(json.dumps(profiles, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(profiles) if isinstance(profiles, list) else 1 + context.report_progress("firewall_profiles_finished", profile_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("firewall profiles collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 3ff84d54..9c779b56 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -24,6 +24,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.active_connections", "core.network.connection_processes", "core.network.dns_cache", + "core.network.firewall_profiles", "core.network.adapter_statistics", "core.network.bandwidth_sample", "core.network.network_interfaces", From 799b52f5ff4b946a6ec97846bc4ee3af3ab43c3a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:34:24 +0300 Subject: [PATCH 051/316] feat: add environment posture collector --- core/system/environment_posture.py | 82 ++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 83 insertions(+) create mode 100644 core/system/environment_posture.py diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py new file mode 100644 index 00000000..b0eb29e2 --- /dev/null +++ b/core/system/environment_posture.py @@ -0,0 +1,82 @@ +"""Export local privilege, UAC, and PowerShell-policy posture as read-only JSON.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common Windows permission-denied wording.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class EnvironmentPostureCollector(CoreCollector): + """Capture local execution and elevation posture without changing system state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the bounded, subprocess-gated system posture artifact contract.""" + return CollectorMetadata( + id="core.system.environment_posture", name="Environment posture", version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports administrator state, UAC settings, and PowerShell execution policies.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), + timeout_seconds=45, maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query local posture and register a bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before environment-posture collection") + context.report_progress("environment_posture_started") + command = ( + "$ErrorActionPreference = 'Stop'; " + "$identity = [Security.Principal.WindowsIdentity]::GetCurrent(); " + "$principal = [Security.Principal.WindowsPrincipal]::new($identity); " + "$uac = Get-ItemProperty -Path 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System' " + "-Name EnableLUA, ConsentPromptBehaviorAdmin, PromptOnSecureDesktop; " + "[pscustomobject]@{ IsAdministrator = $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator); " + "UacEnabled = [bool]$uac.EnableLUA; ConsentPromptBehaviorAdmin = $uac.ConsentPromptBehaviorAdmin; " + "PromptOnSecureDesktop = $uac.PromptOnSecureDesktop; " + "ExecutionPolicies = @(try { Get-ExecutionPolicy -List | Select-Object Scope, ExecutionPolicy } " + "catch { [pscustomobject]@{ Error = $_.Exception.Message } }); " + "CollectedAt = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 4" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=40, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "environment-posture access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "environment-posture query failed", errors=(detail,)) + try: + posture = json.loads(completed.stdout) + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned invalid JSON", errors=(str(error),)) + if not isinstance(posture, dict): + return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned an unexpected result") + output = context.workspace / "environment_posture.json" + output.write_text(json.dumps(posture, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("environment_posture_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("environment posture collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 9c779b56..5deb4762 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -47,6 +47,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.operating_system", "core.system.computer_system", "core.system.session_snapshot", + "core.system.environment_posture", "core.system.system_diagnostics", "core.system.installed_drivers", "core.system.installed_updates", From a67da17fbfa5f0b9553e1c0247687fcd7e247248 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:36:38 +0300 Subject: [PATCH 052/316] feat: add Wi-Fi interface collector --- core/wireless/wifi_interfaces.py | 93 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 94 insertions(+) create mode 100644 core/wireless/wifi_interfaces.py diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py new file mode 100644 index 00000000..c8a9e940 --- /dev/null +++ b/core/wireless/wifi_interfaces.py @@ -0,0 +1,93 @@ +"""Export local Wi-Fi interface state with a normalized interface-name field.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from netsh output.""" + normalized = detail.casefold() + return ( + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "requires elevation" in normalized + ) + + +def _normalize_interface_name(name: str) -> str: + """Normalize the common Wi-Fi and WiFi spelling variation for joins.""" + return "Wi-Fi" if name.strip().casefold() in {"wifi", "wi-fi"} else name.strip() + + +class WifiInterfacesCollector(CoreCollector): + """Capture Wi-Fi interface metadata without requesting profile key material.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the bounded, subprocess-gated wireless interface artifact contract.""" + return CollectorMetadata( + id="core.wireless.wifi_interfaces", name="Wi-Fi interfaces", version="4.0.0", + specialty=Specialty.WIRELESS, + description="Exports local Wi-Fi interface state and normalized interface names without key material.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_configuration",), default_profiles=("deep",), + timeout_seconds=30, maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and netsh availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("netsh") is None: + return ValidationResult(False, reasons=("netsh is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query Wi-Fi interface output and register normalized JSON evidence.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Wi-Fi interface collection") + context.report_progress("wifi_interfaces_started") + completed = subprocess.run( + ["netsh", "wlan", "show", "interfaces"], + capture_output=True, check=False, text=True, timeout=25, + ) + detail = completed.stderr.strip() + if completed.returncode != 0: + message = detail or completed.stdout.strip() or f"netsh exit code {completed.returncode}" + if _is_access_denied(message): + return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi interface access was denied for the current account", errors=(message,)) + return CollectorResult(CollectorStatus.FAILED, "Wi-Fi interface query failed", errors=(message,)) + + interfaces: list[dict[str, str]] = [] + current: dict[str, str] = {} + for raw_line in completed.stdout.splitlines(): + line = raw_line.strip() + if not line or ":" not in line: + continue + key, value = (part.strip() for part in line.split(":", 1)) + key = key.casefold().replace(" ", "_") + if key == "name" and current: + interfaces.append(current) + current = {} + current[key] = value + if current: + interfaces.append(current) + for interface in interfaces: + name = interface.get("name", "") + interface["normalized_name"] = _normalize_interface_name(name) + + report = {"interfaces": interfaces, "raw_output": completed.stdout.strip()} + output = context.workspace / "wifi_interfaces.json" + output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("wifi_interfaces_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Wi-Fi interfaces collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because netsh exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 5deb4762..6d537fe3 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -55,6 +55,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.group_policy", "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", + "core.wireless.wifi_interfaces", "core.hardware.windows_features", "core.hardware.battery_status", "core.event_log.system_events", From 6a6da4768336e8ea09563e98be843e5392b67f8c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:38:25 +0300 Subject: [PATCH 053/316] feat: add Wi-Fi profile key collector --- core/wireless/wifi_profile_keys.py | 72 ++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 73 insertions(+) create mode 100644 core/wireless/wifi_profile_keys.py diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py new file mode 100644 index 00000000..a706d6b1 --- /dev/null +++ b/core/wireless/wifi_profile_keys.py @@ -0,0 +1,72 @@ +"""Export saved Windows Wi-Fi profiles with key material after explicit approval.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common Windows permission-denied or elevation-required wording.""" + normalized = detail.casefold() + return ( + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "requires elevation" in normalized + ) + + +class WifiProfileKeysCollector(CoreCollector): + """Export saved Wi-Fi profiles and credentials only after sensitive approval.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent wireless credential export contract.""" + return CollectorMetadata( + id="core.wireless.wifi_profile_keys", name="Saved Wi-Fi profile keys", version="4.0.0", + specialty=Specialty.WIRELESS, + description="Exports saved Wi-Fi profile XML including key material after explicit sensitive-data approval.", + author="Logicytics", supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS, Capability.SENSITIVE_FILES), + sensitive_data_categories=("wireless_profile_names", "credentials"), default_profiles=("deep",), + timeout_seconds=60, maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and netsh availability before credential export.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("netsh") is None: + return ValidationResult(False, reasons=("netsh is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Export profile XML into this collector's private workspace and register it.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Wi-Fi profile-key collection") + context.report_progress("wifi_profile_keys_started") + export_directory = context.workspace / "wifi_profiles_with_keys" + export_directory.mkdir() + completed = subprocess.run( + ["netsh", "wlan", "export", "profile", f"folder={export_directory}", "key=clear"], + capture_output=True, check=False, text=True, timeout=55, + ) + detail = completed.stderr.strip() or completed.stdout.strip() + if completed.returncode != 0: + message = detail or f"netsh exit code {completed.returncode}" + if _is_access_denied(message): + return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi profile-key access was denied for the current account", errors=(message,)) + return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile-key export failed", errors=(message,)) + profiles = sorted(path for path in export_directory.glob("*.xml") if path.is_file()) + if not profiles: + return CollectorResult(CollectorStatus.SKIPPED, "no saved Wi-Fi profiles with key material were exported") + artifacts = tuple(context.artifacts.register_file(Path(profile), media_type="application/xml") for profile in profiles) + context.report_progress("wifi_profile_keys_finished", profile_count=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + return CollectorResult.succeeded("saved Wi-Fi profile keys collected", artifacts) + + def cleanup(self, context: CollectorContext) -> None: + """Leave cleanup to the isolated collector workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 6d537fe3..3ec11960 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -56,6 +56,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", "core.wireless.wifi_interfaces", + "core.wireless.wifi_profile_keys", "core.hardware.windows_features", "core.hardware.battery_status", "core.event_log.system_events", From c486ec6ce1e3201a0ed91f6cc15fe84932b2eef9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:42:36 +0300 Subject: [PATCH 054/316] feat: add HKLM registry backup collector --- core/registry/hklm_backup.py | 69 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 70 insertions(+) create mode 100644 core/registry/hklm_backup.py diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py new file mode 100644 index 00000000..efffc117 --- /dev/null +++ b/core/registry/hklm_backup.py @@ -0,0 +1,69 @@ +"""Export the local HKLM registry hive to a sensitive, read-only .reg artifact.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize Windows permission-denied or elevation-required wording.""" + normalized = detail.casefold() + return ( + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "requires elevation" in normalized + ) + + +class HklmBackupCollector(CoreCollector): + """Create a sensitive registry export solely inside the isolated workspace.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent HKLM registry-export artifact contract.""" + return CollectorMetadata( + id="core.registry.hklm_backup", name="HKLM registry backup", version="4.0.0", + specialty=Specialty.REGISTRY, + description="Exports the local HKLM hive as a .reg backup after explicit sensitive-data approval.", + author="Logicytics", supported_platforms=("win32",), + capabilities=(Capability.REGISTRY_READ, Capability.SUBPROCESS, Capability.SENSITIVE_FILES), + sensitive_data_categories=("registry", "system_configuration", "credentials"), default_profiles=("deep",), + timeout_seconds=300, maximum_output_bytes=2 * 1024 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and the Windows registry tool before export.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("reg") is None: + return ValidationResult(False, reasons=("reg.exe is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Export HKLM to the private workspace and register the resulting artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before HKLM registry backup") + context.report_progress("hklm_backup_started") + output = context.workspace / "hklm_backup.reg" + completed = subprocess.run( + ["reg", "export", "HKLM", str(output), "/y"], + capture_output=True, check=False, text=True, timeout=290, + ) + detail = completed.stderr.strip() or completed.stdout.strip() + if completed.returncode != 0: + message = detail or f"reg.exe exit code {completed.returncode}" + if _is_access_denied(message): + return CollectorResult(CollectorStatus.SKIPPED, "HKLM registry export was denied for the current account", errors=(message,)) + return CollectorResult(CollectorStatus.FAILED, "HKLM registry export failed", errors=(message,)) + if not output.is_file() or output.stat().st_size == 0: + return CollectorResult(CollectorStatus.FAILED, "HKLM registry export produced no backup artifact") + artifact = context.artifacts.register_file(output, media_type="text/plain") + context.report_progress("hklm_backup_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("HKLM registry backup collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Leave removal to the isolated collector workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 3ec11960..31a475c5 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -36,6 +36,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.detailed_processes", "core.process.process_memory", "core.registry.installed_applications", + "core.registry.hklm_backup", "core.registry.startup_applications", "core.storage.logical_drives", "core.storage.physical_disks", From 4b70c6c60ff2fb6529372c985830fe72ca9a765e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:45:51 +0300 Subject: [PATCH 055/316] feat: add SSH backup collector --- core/ssh/ssh_backup.py | 86 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 87 insertions(+) create mode 100644 core/ssh/ssh_backup.py diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py new file mode 100644 index 00000000..a3889d99 --- /dev/null +++ b/core/ssh/ssh_backup.py @@ -0,0 +1,86 @@ +"""Archive the current user's SSH directory after explicit sensitive approval.""" + +from __future__ import annotations + +import zipfile +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +MAX_FILE_BYTES = 10 * 1024 * 1024 +MAX_ARCHIVE_SOURCE_BYTES = 128 * 1024 * 1024 + + +class SshBackupCollector(CoreCollector): + """Back up current-user SSH keys and configuration into the private workspace.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent SSH archive artifact contract.""" + return CollectorMetadata( + id="core.ssh.ssh_backup", name="SSH directory backup", version="4.0.0", specialty=Specialty.SSH, + description="Archives the current user's .ssh keys and configuration after explicit sensitive-data approval.", + author="Logicytics", supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), + sensitive_data_categories=("private_keys", "ssh_configuration", "credentials"), default_profiles=("deep",), + timeout_seconds=120, maximum_output_bytes=128 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state before scanning the current user's SSH directory.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Archive bounded regular files from .ssh while preserving relative paths.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before SSH backup") + ssh_directory = Path.home() / ".ssh" + try: + directory_exists = ssh_directory.is_dir() + except OSError as error: + return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", errors=(str(error),)) + if not directory_exists: + return CollectorResult(CollectorStatus.SKIPPED, "the current user has no .ssh directory") + context.report_progress("ssh_backup_started") + archive = context.workspace / "ssh_backup.zip" + source_bytes = 0 + archived_files = 0 + skipped_files = 0 + with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output: + try: + candidates = sorted(ssh_directory.rglob("*")) + except OSError as error: + return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", errors=(str(error),)) + for candidate in candidates: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during SSH backup") + try: + is_file = candidate.is_file() + is_symlink = candidate.is_symlink() + size = candidate.stat().st_size if is_file else 0 + except OSError: + skipped_files += 1 + continue + if not is_file or is_symlink: + continue + if size > MAX_FILE_BYTES or source_bytes + size > MAX_ARCHIVE_SOURCE_BYTES: + skipped_files += 1 + continue + output.write(candidate, arcname=candidate.relative_to(ssh_directory).as_posix()) + source_bytes += size + archived_files += 1 + if archived_files == 0: + return CollectorResult(CollectorStatus.SKIPPED, "no SSH files met the bounded backup policy") + artifact = context.artifacts.register_file(archive, media_type="application/zip") + context.report_progress( + "ssh_backup_finished", archived_files=archived_files, skipped_files=skipped_files, + source_bytes=source_bytes, bytes_written=artifact.size_bytes, + ) + return CollectorResult.succeeded("SSH directory backup collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Leave archive removal to the isolated collector workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 31a475c5..ba56db0e 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -42,6 +42,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.storage.physical_disks", "core.storage.mounted_volumes", "core.storage.volume_details", + "core.ssh.ssh_backup", "core.system.system_info", "core.system.system_details", "core.system.bios_info", From 394ea04e1d32f2d45bb69dce89eecf0d5d13ecaa Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:48:11 +0300 Subject: [PATCH 056/316] feat: add media backup collector --- core/media/media_backup.py | 96 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 97 insertions(+) create mode 100644 core/media/media_backup.py diff --git a/core/media/media_backup.py b/core/media/media_backup.py new file mode 100644 index 00000000..1b3779be --- /dev/null +++ b/core/media/media_backup.py @@ -0,0 +1,96 @@ +"""Back up bounded current-user Pictures and Videos evidence after explicit approval.""" + +from __future__ import annotations + +import shutil +from datetime import datetime, timezone +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} +MAX_FILE_BYTES = 50 * 1024 * 1024 +MAX_TOTAL_BYTES = 512 * 1024 * 1024 +MAX_FILES = 1_000 + + +class MediaBackupCollector(CoreCollector): + """Copy supported current-user media into the collector's private workspace.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent media backup artifact contract.""" + return CollectorMetadata( + id="core.media.media_backup", name="Pictures and Videos backup", version="4.0.0", specialty=Specialty.MEDIA, + description="Copies bounded JPG, JPEG, PNG, and MP4 files from current-user Pictures and Videos folders.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + sensitive_data_categories=("personal_media",), default_profiles=("deep",), timeout_seconds=300, maximum_output_bytes=512 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state before the bounded media scan starts.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Copy supported regular media files while preserving source metadata.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before media backup") + source_roots = (("pictures", Path.home() / "Pictures"), ("videos", Path.home() / "Videos")) + destination_root = context.workspace / "media_backup" + copied: list[Path] = [] + copied_bytes = 0 + skipped_files = 0 + inaccessible_roots = 0 + context.report_progress("media_backup_started") + for category, source_root in source_roots: + try: + exists = source_root.is_dir() + except OSError: + inaccessible_roots += 1 + continue + if not exists: + continue + try: + candidates = source_root.rglob("*") + for candidate in candidates: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during media backup") + if len(copied) >= MAX_FILES: + break + try: + if not candidate.is_file() or candidate.is_symlink() or candidate.suffix.casefold() not in SUPPORTED_EXTENSIONS: + continue + size = candidate.stat().st_size + except OSError: + skipped_files += 1 + continue + if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES: + skipped_files += 1 + continue + timestamp = datetime.fromtimestamp(candidate.stat().st_mtime, timezone.utc).strftime("%Y%m%dT%H%M%SZ") + destination = destination_root / category / f"{candidate.stem}_{timestamp}{candidate.suffix.casefold()}" + suffix = 1 + while destination.exists(): + destination = destination_root / category / f"{candidate.stem}_{timestamp}_{suffix}{candidate.suffix.casefold()}" + suffix += 1 + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(candidate, destination) + copied.append(destination) + copied_bytes += size + except OSError: + inaccessible_roots += 1 + if not copied: + reason = "no supported media files met the bounded backup policy" + if inaccessible_roots == len(source_roots): + reason = "the current user's Pictures and Videos folders are inaccessible" + return CollectorResult(CollectorStatus.SKIPPED, reason) + artifacts = tuple(context.artifacts.register_file(path) for path in copied) + context.report_progress("media_backup_finished", copied_files=len(artifacts), skipped_files=skipped_files, bytes_written=sum(item.size_bytes for item in artifacts)) + return CollectorResult.succeeded("current-user media backup collected", artifacts) + + def cleanup(self, context: CollectorContext) -> None: + """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index ba56db0e..21f99f46 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -32,6 +32,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.arp_cache", "core.network.routing_table", "core.memory.memory_snapshot", + "core.media.media_backup", "core.process.running_processes", "core.process.detailed_processes", "core.process.process_memory", From 3ff79fbc162a41d4d0c24c42ed10e1912df8e1bc Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:50:00 +0300 Subject: [PATCH 057/316] feat: add browser data backup collector --- core/browser/browser_data_backup.py | 94 +++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 95 insertions(+) create mode 100644 core/browser/browser_data_backup.py diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py new file mode 100644 index 00000000..1a9b421d --- /dev/null +++ b/core/browser/browser_data_backup.py @@ -0,0 +1,94 @@ +"""Copy bounded browser-profile evidence after explicit browser-data approval.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +CHROMIUM_FILES = ("History", "History-journal", "Cookies", "Cookies-journal", "Login Data", "Login Data-journal", "Bookmarks", "Preferences") +FIREFOX_FILES = ("places.sqlite", "cookies.sqlite", "logins.json", "key4.db", "prefs.js") +MAX_FILE_BYTES = 50 * 1024 * 1024 +MAX_TOTAL_BYTES = 256 * 1024 * 1024 + + +def _copy_file(source: Path, destination: Path, copied_bytes: int) -> tuple[Path | None, int]: + """Copy one bounded regular file, preserving metadata, or return no artifact.""" + try: + if not source.is_file() or source.is_symlink(): + return None, copied_bytes + size = source.stat().st_size + except OSError: + return None, copied_bytes + if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES: + return None, copied_bytes + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + return destination, copied_bytes + size + + +class BrowserDataBackupCollector(CoreCollector): + """Copy bounded data from supported local browser profiles into a private workspace.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent browser-data artifact contract.""" + return CollectorMetadata( + id="core.browser.browser_data_backup", name="Browser data backup", version="4.0.0", specialty=Specialty.BROWSER, + description="Copies bounded local profile evidence from Edge, Chrome, Firefox, Opera, and Opera GX.", + author="Logicytics", supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ, Capability.BROWSER_DATA, Capability.SENSITIVE_FILES), + sensitive_data_categories=("browser_history", "cookies", "credentials"), default_profiles=("deep",), + timeout_seconds=300, maximum_output_bytes=256 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state before browser-profile copying starts.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Copy supported profile files from configured local browser locations.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before browser data backup") + local = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) + roaming = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + chromium_roots = (("chrome", local / "Google" / "Chrome" / "User Data"), ("edge", local / "Microsoft" / "Edge" / "User Data"), ("opera", roaming / "Opera Software" / "Opera Stable"), ("opera_gx", roaming / "Opera Software" / "Opera GX Stable")) + copied: list[Path] = [] + copied_bytes = 0 + context.report_progress("browser_data_backup_started") + for browser, root in chromium_roots: + try: + profile_roots = [root] if browser.startswith("opera") else [path for path in root.iterdir() if path.is_dir() and (path.name == "Default" or path.name.startswith("Profile "))] + except OSError: + continue + for profile in profile_roots: + for filename in CHROMIUM_FILES: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") + destination, copied_bytes = _copy_file(profile / filename, context.workspace / "browser_data" / browser / profile.name / filename, copied_bytes) + if destination is not None: + copied.append(destination) + firefox_profiles = roaming / "Mozilla" / "Firefox" / "Profiles" + try: + profiles = [path for path in firefox_profiles.iterdir() if path.is_dir()] + except OSError: + profiles = [] + for profile in profiles: + for filename in FIREFOX_FILES: + destination, copied_bytes = _copy_file(profile / filename, context.workspace / "browser_data" / "firefox" / profile.name / filename, copied_bytes) + if destination is not None: + copied.append(destination) + if not copied: + return CollectorResult(CollectorStatus.SKIPPED, "no supported local browser profile data met the bounded backup policy") + artifacts = tuple(context.artifacts.register_file(path) for path in copied) + context.report_progress("browser_data_backup_finished", copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + return CollectorResult.succeeded("browser data backup collected", artifacts) + + def cleanup(self, context: CollectorContext) -> None: + """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 21f99f46..6cd11ef1 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -20,6 +20,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: { "core.bluetooth.paired_devices", "core.bluetooth.bluetooth_addresses", + "core.browser.browser_data_backup", "core.network.network_identity", "core.network.active_connections", "core.network.connection_processes", From 5937b89fcf372432a3f136711afa87b29f502481 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:52:04 +0300 Subject: [PATCH 058/316] feat: add Bluetooth history collector --- core/bluetooth/bluetooth_history.py | 66 +++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 67 insertions(+) create mode 100644 core/bluetooth/bluetooth_history.py diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py new file mode 100644 index 00000000..aec3a61d --- /dev/null +++ b/core/bluetooth/bluetooth_history.py @@ -0,0 +1,66 @@ +"""Write a timestamped Bluetooth-device snapshot for retained run-history evidence.""" + +from __future__ import annotations + +import json +import subprocess +from datetime import datetime, timezone +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common Windows permission-denied wording.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class BluetoothHistoryCollector(CoreCollector): + """Export a timestamped Bluetooth snapshot without mutating shared history state.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the bounded Bluetooth-history JSON artifact contract.""" + return CollectorMetadata( + id="core.bluetooth.bluetooth_history", name="Bluetooth history snapshot", version="4.0.0", specialty=Specialty.BLUETOOTH, + description="Exports a timestamped Bluetooth PnP snapshot; retained run packages provide historical evidence.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("device_identifiers",), default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query Bluetooth PnP state and register a timestamped JSON snapshot.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-history collection") + context.report_progress("bluetooth_history_started") + command = "$ErrorActionPreference = 'Stop'; @(Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Status, Problem, Present) | ConvertTo-Json -Depth 3" + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Bluetooth-history access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query failed", errors=(detail,)) + try: + devices = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query returned invalid JSON", errors=(str(error),)) + snapshot = {"collected_at": datetime.now(timezone.utc).isoformat(), "devices": devices if isinstance(devices, list) else [devices]} + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + output = context.workspace / f"bluetooth_history_{stamp}.json" + output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("bluetooth_history_finished", device_count=len(snapshot["devices"]), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Bluetooth history snapshot collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Leave temporary snapshot removal to the isolated workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 6cd11ef1..207cc3d0 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -20,6 +20,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: { "core.bluetooth.paired_devices", "core.bluetooth.bluetooth_addresses", + "core.bluetooth.bluetooth_history", "core.browser.browser_data_backup", "core.network.network_identity", "core.network.active_connections", From 36543a216fd0baa89968c4e12cc2f2bee8138f5f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:53:56 +0300 Subject: [PATCH 059/316] feat: add packet capture collector --- core/packet/packet_capture.py | 106 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 107 insertions(+) create mode 100644 core/packet/packet_capture.py diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py new file mode 100644 index 00000000..e8e430c2 --- /dev/null +++ b/core/packet/packet_capture.py @@ -0,0 +1,106 @@ +"""Capture bounded local IPv4 packet observations after explicit approval.""" + +from __future__ import annotations + +import csv +import select +import socket +import struct +import time + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _packet_row(payload: bytes) -> dict[str, str] | None: + """Decode a minimal IPv4/TCP/UDP/ICMP observation without retaining payload data.""" + if len(payload) < 20 or payload[0] >> 4 != 4: + return None + header_length = (payload[0] & 15) * 4 + if header_length < 20 or len(payload) < header_length: + return None + protocol = payload[9] + names = {1: "ICMP", 6: "TCP", 17: "UDP"} + source = socket.inet_ntoa(payload[12:16]) + destination = socket.inet_ntoa(payload[16:20]) + source_port = destination_port = "" + if protocol in {6, 17} and len(payload) >= header_length + 4: + source_port, destination_port = (str(value) for value in struct.unpack("!HH", payload[header_length:header_length + 4])) + return {"source_ip": source, "destination_ip": destination, "protocol": names.get(protocol, str(protocol)), "source_port": source_port, "destination_port": destination_port, "packet_bytes": str(len(payload))} + + +class PacketCaptureCollector(CoreCollector): + """Capture metadata-only packet observations inside the isolated worker process.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicitly approved packet-capture CSV artifact contract.""" + return CollectorMetadata( + id="core.packet.packet_capture", name="IPv4 packet capture", version="4.0.0", specialty=Specialty.PACKET, + description="Captures bounded IPv4 packet metadata without saving packet payloads.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.NETWORK, Capability.PACKET_CAPTURE, Capability.ELEVATED_PRIVILEGES), + sensitive_data_categories=("network_metadata",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Validate bounded capture settings before raw-socket creation.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + count = int(context.settings.get("packet_count", 100)) + timeout = float(context.settings.get("timeout_seconds", 10)) + except (TypeError, ValueError): + return ValidationResult(False, reasons=("packet_count and timeout_seconds must be numeric",)) + if not 1 <= count <= 10_000 or not 1 <= timeout <= 60: + return ValidationResult(False, reasons=("packet_count must be 1-10000 and timeout_seconds must be 1-60",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Capture bounded metadata and save it as CSV without retaining payload bytes.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before packet capture") + count = int(context.settings.get("packet_count", 100)) + timeout = float(context.settings.get("timeout_seconds", 10)) + interface = str(context.settings.get("interface", socket.gethostbyname(socket.gethostname()))) + context.report_progress("packet_capture_started", interface=interface, packet_count=count) + capture: socket.socket | None = None + observations: list[dict[str, str]] = [] + try: + capture = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP) + capture.bind((interface, 0)) + capture.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) + capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON) + deadline = time.monotonic() + timeout + while len(observations) < count and time.monotonic() < deadline: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during packet capture") + ready, _, _ = select.select([capture], [], [], min(1.0, deadline - time.monotonic())) + if not ready: + continue + row = _packet_row(capture.recv(65_535)) + if row is not None: + observations.append(row) + except PermissionError as error: + return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture requires an elevated account", errors=(str(error),)) + except OSError as error: + if error.winerror in {5, 10013}: + return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture was denied for the current account", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "raw packet capture failed", errors=(str(error),)) + finally: + if capture is not None: + try: + capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF) + except OSError: + pass + capture.close() + output = context.workspace / "packet_capture.csv" + with output.open("w", newline="", encoding="utf-8") as stream: + writer = csv.DictWriter(stream, fieldnames=("source_ip", "destination_ip", "protocol", "source_port", "destination_port", "packet_bytes")) + writer.writeheader() + writer.writerows(observations) + artifact = context.artifacts.register_file(output, media_type="text/csv") + context.report_progress("packet_capture_finished", observation_count=len(observations), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("packet metadata captured", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Raw socket cleanup happens in collect's finally block.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 207cc3d0..6f32dc11 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -33,6 +33,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.network_adapters", "core.network.arp_cache", "core.network.routing_table", + "core.packet.packet_capture", "core.memory.memory_snapshot", "core.media.media_backup", "core.process.running_processes", From b9f80708130b9dea6468cec0f61e5df84c004665 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:56:11 +0300 Subject: [PATCH 060/316] feat: add sensitive file inventory collector --- core/filesystem/sensitive_file_inventory.py | 93 +++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 94 insertions(+) create mode 100644 core/filesystem/sensitive_file_inventory.py diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py new file mode 100644 index 00000000..551976fe --- /dev/null +++ b/core/filesystem/sensitive_file_inventory.py @@ -0,0 +1,93 @@ +"""Find and copy bounded sensitive-named files after explicit approval.""" + +from __future__ import annotations + +import os +import shutil +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +KEYWORDS = ("password", "secret", "code", "login", "api", "key", "token", "auth", "credential", "private", "certificate", "ssh", "pgp", "wallet") +EXTENSIONS = {".txt", ".csv", ".json", ".xml", ".yml", ".yaml", ".ini", ".cfg", ".conf", ".log", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".db", ".sqlite", ".pem", ".key", ".ppk"} +MAX_FILE_BYTES = 10 * 1024 * 1024 + + +def _copy(source: Path, destination: Path) -> Path | None: + """Copy one regular source file into the private workspace, preserving metadata.""" + try: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + except OSError: + return None + return destination + + +class SensitiveFileInventoryCollector(CoreCollector): + """Search a bounded system-drive subset for sensitive-named supported files.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent sensitive-file inventory artifact contract.""" + return CollectorMetadata( + id="core.filesystem.sensitive_file_inventory", name="Sensitive file inventory", version="4.0.0", specialty=Specialty.FILESYSTEM, + description="Finds and copies bounded supported files with sensitive-data keywords in their names.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + sensitive_data_categories=("credentials", "private_keys", "personal_documents"), default_profiles=("deep",), timeout_seconds=300, maximum_output_bytes=256 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Validate bounded search settings before walking the requested root.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + if int(context.settings.get("max_directories", 5_000)) < 1 or int(context.settings.get("max_matches", 500)) < 1: + return ValidationResult(False, reasons=("max_directories and max_matches must be positive",)) + except (TypeError, ValueError): + return ValidationResult(False, reasons=("max_directories and max_matches must be integers",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Find matches and copy them concurrently into a dedicated evidence directory.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before sensitive-file inventory") + root = Path(str(context.settings.get("root", os.environ.get("SystemDrive", "C:") + "\\"))) + max_directories = int(context.settings.get("max_directories", 5_000)) + max_matches = int(context.settings.get("max_matches", 500)) + matches: list[Path] = [] + scanned_directories = 0 + context.report_progress("sensitive_file_inventory_started", root=str(root)) + for directory, directories, filenames in os.walk(root, onerror=lambda _: None): + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file inventory") + scanned_directories += 1 + if scanned_directories > max_directories or len(matches) >= max_matches: + directories.clear() + break + for filename in filenames: + candidate = Path(directory) / filename + name = filename.casefold() + if candidate.suffix.casefold() not in EXTENSIONS or not any(keyword in name for keyword in KEYWORDS): + continue + try: + if candidate.is_symlink() or candidate.stat().st_size > MAX_FILE_BYTES: + continue + except OSError: + continue + matches.append(candidate) + if len(matches) >= max_matches: + break + destination_root = context.workspace / "sensitive_file_inventory" + with ThreadPoolExecutor(max_workers=4) as executor: + copied = [item for item in executor.map(lambda source: _copy(source, destination_root / source.drive.replace(":", "") / source.relative_to(root)), matches) if item is not None] + if not copied: + return CollectorResult(CollectorStatus.SKIPPED, "no sensitive-named supported files met the bounded inventory policy") + artifacts = tuple(context.artifacts.register_file(path) for path in copied) + context.report_progress("sensitive_file_inventory_finished", scanned_directories=scanned_directories, copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + return CollectorResult.succeeded("sensitive file inventory collected", artifacts) + + def cleanup(self, context: CollectorContext) -> None: + """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 6f32dc11..f5c72095 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -69,6 +69,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.event_log.application_events", "core.event_log.security_events", "core.filesystem.system_drive_tree", + "core.filesystem.sensitive_file_inventory", "core.filesystem.system_drive_listing", "core.encryption.bitlocker_status", "core.encryption.bitlocker_volumes", From e22eccea7f1e59c3e777428c6f967cf94f455f44 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:58:00 +0300 Subject: [PATCH 061/316] feat: add Windows system data backup collector --- core/system/windows_system_data_backup.py | 70 +++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 71 insertions(+) create mode 100644 core/system/windows_system_data_backup.py diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py new file mode 100644 index 00000000..ca2c7f5d --- /dev/null +++ b/core/system/windows_system_data_backup.py @@ -0,0 +1,70 @@ +"""Copy bounded Windows policy, event-log, and security-support evidence.""" + +from __future__ import annotations + +import os +import shutil +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +MAX_FILE_BYTES = 100 * 1024 * 1024 + + +class WindowsSystemDataBackupCollector(CoreCollector): + """Back up bounded Windows system-data paths into labeled private directories.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the explicit-consent Windows system-data backup contract.""" + return CollectorMetadata( + id="core.system.windows_system_data_backup", name="Windows system-data backup", version="4.0.0", specialty=Specialty.SYSTEM, + description="Copies bounded Group Policy, event-log, and Windows security-support evidence.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + sensitive_data_categories=("security_logs", "group_policy", "system_configuration"), default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=256 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state before system-data copying starts.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Copy supported Windows system-data files and preserve source metadata.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Windows system-data backup") + windows = Path(os.environ.get("SystemRoot", r"C:\Windows")) + program_data = Path(os.environ.get("ProgramData", r"C:\ProgramData")) + candidates = [ + ("group_policy", windows / "System32" / "GroupPolicy" / "Machine" / "Registry.pol"), + ("group_policy", windows / "System32" / "GroupPolicy" / "User" / "Registry.pol"), + ("event_logs", windows / "System32" / "winevt" / "Logs" / "System.evtx"), + ("event_logs", windows / "System32" / "winevt" / "Logs" / "Application.evtx"), + ("event_logs", windows / "System32" / "winevt" / "Logs" / "Security.evtx"), + ] + candidates.extend(("security_support", path) for path in (program_data / "Microsoft" / "Windows Defender" / "Support").glob("*.log")) + copied: list[Path] = [] + context.report_progress("windows_system_data_backup_started") + for label, source in candidates: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Windows system-data backup") + try: + if not source.is_file() or source.is_symlink() or source.stat().st_size > MAX_FILE_BYTES: + continue + destination = context.workspace / "windows_system_data" / label / source.name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + except OSError: + continue + copied.append(destination) + if not copied: + return CollectorResult(CollectorStatus.SKIPPED, "no configured Windows system-data files met the bounded backup policy") + artifacts = tuple(context.artifacts.register_file(path) for path in copied) + context.report_progress("windows_system_data_backup_finished", copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + return CollectorResult.succeeded("Windows system-data backup collected", artifacts) + + def cleanup(self, context: CollectorContext) -> None: + """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index f5c72095..9f097ff1 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -58,6 +58,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.installed_drivers", "core.system.installed_updates", "core.system.windows_services", + "core.system.windows_system_data_backup", "core.system.group_policy", "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", From 064b5e8a4a91b611c4b8afcebecc21a8af97666e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 01:59:24 +0300 Subject: [PATCH 062/316] feat: add connection graph collector --- core/packet/connection_graph.py | 71 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 72 insertions(+) create mode 100644 core/packet/connection_graph.py diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py new file mode 100644 index 00000000..14ade079 --- /dev/null +++ b/core/packet/connection_graph.py @@ -0,0 +1,71 @@ +"""Export a labeled local connection graph in DOT format.""" + +from __future__ import annotations + +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common Windows permission-denied wording.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ConnectionGraphCollector(CoreCollector): + """Build a DOT connection graph without retaining packet payloads.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated network graph artifact contract.""" + return CollectorMetadata( + id="core.packet.connection_graph", name="Connection graph", version="4.0.0", specialty=Specialty.PACKET, + description="Exports a DOT source/destination graph with TCP or UDP protocol edge labels.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_metadata",), + default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and netstat availability before graph collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("netstat") is None: + return ValidationResult(False, reasons=("netstat is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Read active connections and write a bounded DOT graph artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection graph collection") + context.report_progress("connection_graph_started") + completed = subprocess.run(["netstat", "-ano"], capture_output=True, check=False, text=True, timeout=40) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "connection graph access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "connection graph query failed", errors=(detail,)) + edges: set[tuple[str, str, str]] = set() + for line in completed.stdout.splitlines(): + fields = line.split() + if len(fields) < 4 or fields[0].upper() not in {"TCP", "UDP"}: + continue + protocol, source, destination = fields[:3] + if destination in {"*:*", "*"}: + continue + edges.add((source, destination, protocol.upper())) + def quote(value: str) -> str: + return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' + lines = ["digraph connection_graph {", " rankdir=LR;"] + lines.extend(f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" for source, destination, protocol in sorted(edges)) + lines.append("}") + output = context.workspace / "connection_graph.dot" + output.write_text("\n".join(lines) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/vnd.graphviz") + context.report_progress("connection_graph_finished", edge_count=len(edges), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("connection graph collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because netstat exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 9f097ff1..0dff95ee 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -34,6 +34,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.network.arp_cache", "core.network.routing_table", "core.packet.packet_capture", + "core.packet.connection_graph", "core.memory.memory_snapshot", "core.media.media_backup", "core.process.running_processes", From a8ddd99f651803fbb78021aa16a239f5bfe0230b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:01:01 +0300 Subject: [PATCH 063/316] feat: add Sysinternals report collector --- core/diagnostics/sysinternals_report.py | 67 +++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 68 insertions(+) create mode 100644 core/diagnostics/sysinternals_report.py diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py new file mode 100644 index 00000000..af0e8488 --- /dev/null +++ b/core/diagnostics/sysinternals_report.py @@ -0,0 +1,67 @@ +"""Discover and report bounded output from supported local Sysinternals tools.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +TOOLS = ("psfile", "psgetsid", "psinfo", "pslist", "psloggedon", "psloglist") +MAX_OUTPUT_CHARS = 512_000 + + +class SysinternalsReportCollector(CoreCollector): + """Run available local Sysinternals tools and consolidate their diagnostic output.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated Sysinternals report artifact contract.""" + return CollectorMetadata( + id="core.diagnostics.sysinternals_report", name="Sysinternals report", version="4.0.0", specialty=Specialty.DIAGNOSTICS, + description="Reports supported Sysinternals binary/archive state and consolidates available tool output.", author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_diagnostics",), + default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=512 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state before local tool discovery.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Discover local binaries, execute available tools, and write one text report.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Sysinternals collection") + project_root = Path(__file__).resolve().parents[2] + search_roots = (project_root / "sysinternals", project_root / "tools" / "sysinternals", Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Sysinternals") + archive_paths = tuple(root.with_suffix(".zip") for root in search_roots) + sections: list[str] = ["Sysinternals report", ""] + context.report_progress("sysinternals_report_started") + for tool in TOOLS: + binary = next((root / f"{tool}.exe" for root in search_roots if (root / f"{tool}.exe").is_file()), None) + sections.append(f"## {tool}") + if binary is None: + archive_state = "archive available" if any(path.is_file() for path in archive_paths) else "binary and archive missing" + sections.extend((f"status: {archive_state}", "")) + continue + try: + completed = subprocess.run([str(binary)], capture_output=True, check=False, text=True, timeout=25) + except OSError as error: + sections.extend((f"status: execution error: {error}", "")) + continue + output = (completed.stdout + ("\n" if completed.stdout and completed.stderr else "") + completed.stderr).strip() + sections.extend((f"status: executed (exit {completed.returncode})", output[:MAX_OUTPUT_CHARS], "")) + report = "\n".join(sections) + output_path = context.workspace / "sysinternals_report.txt" + output_path.write_text(report[:MAX_OUTPUT_CHARS], encoding="utf-8") + artifact = context.artifacts.register_file(output_path, media_type="text/plain") + context.report_progress("sysinternals_report_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Sysinternals report collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because each tool exits before its output is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 0dff95ee..31b9b099 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -60,6 +60,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.installed_updates", "core.system.windows_services", "core.system.windows_system_data_backup", + "core.diagnostics.sysinternals_report", "core.system.group_policy", "core.usb.usb_storage_inventory", "core.wireless.wifi_profiles", From 2afb0c8e87851d78b75f507bda1aa3254219a4e4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:03:42 +0300 Subject: [PATCH 064/316] feat: enforce configuration schema version --- logicytics/configuration.py | 12 +++++++++++- tests/test_core_functionality.py | 13 ++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/logicytics/configuration.py b/logicytics/configuration.py index e51f8d19..29670d3a 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -10,6 +10,9 @@ from logicytics.errors import PlanError +SCHEMA_VERSION = 4 + + @dataclass(frozen=True, slots=True) class RuntimeSettings: """Engine-wide limits that apply before a collector is started.""" @@ -24,6 +27,7 @@ class RuntimeSettings: class AppConfig: """Validated settings loaded from an optional JSON configuration file.""" + schema_version: int runtime: RuntimeSettings collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) @@ -40,7 +44,7 @@ def to_manifest_dict(self) -> dict[str, Any]: def default_config(project_root: Path) -> AppConfig: """Create safe defaults rooted at the checked-out project.""" - return AppConfig(runtime=RuntimeSettings(output_root=project_root / "ACCESS" / "RUNS")) + return AppConfig(schema_version=SCHEMA_VERSION, runtime=RuntimeSettings(output_root=project_root / "ACCESS" / "RUNS")) def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: @@ -54,6 +58,11 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError(f"invalid configuration file {path}: {error}") from error if not isinstance(raw, dict): raise PlanError("configuration root must be a JSON object") + schema_version = raw.get("schema_version", SCHEMA_VERSION) + if not isinstance(schema_version, int): + raise PlanError("configuration schema_version must be an integer") + if schema_version != SCHEMA_VERSION: + raise PlanError(f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION}") runtime_raw = raw.get("runtime", {}) if not isinstance(runtime_raw, dict): @@ -75,6 +84,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi ): raise PlanError("collectors configuration must map collector IDs to objects") return AppConfig( + schema_version=schema_version, runtime=RuntimeSettings( output_root=output_root, default_max_workers=default_workers, diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index fc9d6a18..a60803c4 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -7,7 +7,7 @@ from pathlib import Path from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.configuration import default_config +from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight from logicytics.errors import ArtifactError, PlanError, PreflightError @@ -57,6 +57,17 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_configuration_schema_version_is_enforced(self) -> None: + """Only the v4 configuration schema may be loaded for a v4 run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + config_path.write_text('{"schema_version": 3}', encoding="utf-8") + with self.assertRaises(PlanError): + load_config(root) + config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") + self.assertEqual(4, load_config(root).schema_version) + """Validate the foundation before real core collectors are added.""" def test_artifacts_cannot_escape_collector_workspace(self) -> None: From 9588a6b48f4081e04a681cecd9c20e511ff07da7 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:04:55 +0300 Subject: [PATCH 065/316] feat: add reusable command runner --- logicytics/command_runner.py | 40 ++++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 7 ++++++ 2 files changed, 47 insertions(+) create mode 100644 logicytics/command_runner.py diff --git a/logicytics/command_runner.py b/logicytics/command_runner.py new file mode 100644 index 00000000..cdc93937 --- /dev/null +++ b/logicytics/command_runner.py @@ -0,0 +1,40 @@ +"""Safe, reusable command execution and structured output parsing for collectors.""" + +from __future__ import annotations + +import subprocess +from dataclasses import dataclass +from typing import Iterable + + +@dataclass(frozen=True, slots=True) +class CommandResult: + """Captured result of a non-shell command invocation.""" + + command: tuple[str, ...] + returncode: int + stdout: str + stderr: str + + +def run_command(command: Iterable[str], *, timeout_seconds: float = 30) -> CommandResult: + """Run an explicit command without a shell and return decoded captured streams.""" + normalized = tuple(str(argument) for argument in command) + if not normalized: + raise ValueError("command must contain at least one argument") + if timeout_seconds <= 0: + raise ValueError("timeout_seconds must be positive") + completed = subprocess.run(normalized, capture_output=True, check=False, text=True, timeout=timeout_seconds) + return CommandResult(normalized, completed.returncode, completed.stdout, completed.stderr) + + +def parse_level_messages(output: str) -> tuple[tuple[str, str], ...]: + """Parse non-Python `LEVEL: message` lines into normalized structured entries.""" + messages: list[tuple[str, str]] = [] + for line in output.splitlines(): + level, separator, message = line.partition(":") + normalized_level = level.strip().upper() + if not separator or normalized_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION"}: + continue + messages.append((normalized_level, message.strip())) + return tuple(messages) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a60803c4..a533aca4 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -7,6 +7,7 @@ from pathlib import Path from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.command_runner import parse_level_messages, run_command from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight @@ -57,6 +58,12 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_command_runner_captures_output_and_parses_structured_levels(self) -> None: + """Core command execution must avoid a shell and preserve structured output.""" + result = run_command(("python", "-c", "print('INFO: collected'); print('ordinary')")) + self.assertEqual(0, result.returncode) + self.assertEqual((("INFO", "collected"),), parse_level_messages(result.stdout)) + def test_configuration_schema_version_is_enforced(self) -> None: """Only the v4 configuration schema may be loaded for a v4 run.""" with tempfile.TemporaryDirectory() as temporary: From 1d254fa955e5753719b1e413c7592d03b7089476 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:06:18 +0300 Subject: [PATCH 066/316] feat: add recursive file listing utility --- logicytics/file_listing.py | 37 ++++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 13 +++++++++++ 2 files changed, 50 insertions(+) create mode 100644 logicytics/file_listing.py diff --git a/logicytics/file_listing.py b/logicytics/file_listing.py new file mode 100644 index 00000000..a337d8ea --- /dev/null +++ b/logicytics/file_listing.py @@ -0,0 +1,37 @@ +"""Safe recursive file discovery utilities for engine and collector use.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Iterable + + +def list_files( + root: Path, + *, + extensions: Iterable[str] = (), + excluded_names: Iterable[str] = (), + excluded_directories: Iterable[str] = (), + append_to: Iterable[Path] = (), +) -> tuple[Path, ...]: + """Return sorted normalized files under root, applying explicit filters safely.""" + resolved_root = root.resolve() + normalized_extensions = {extension.casefold() if extension.startswith(".") else f".{extension.casefold()}" for extension in extensions} + names = {name.casefold() for name in excluded_names} + directories = {name.casefold() for name in excluded_directories} + results = {Path(path).resolve() for path in append_to} + if not resolved_root.is_dir(): + return tuple(sorted(results)) + for path in resolved_root.rglob("*"): + try: + relative = path.resolve().relative_to(resolved_root) + except (OSError, ValueError): + continue + if any(part.casefold() in directories for part in relative.parts[:-1]): + continue + if not path.is_file() or path.name.casefold() in names: + continue + if normalized_extensions and path.suffix.casefold() not in normalized_extensions: + continue + results.add(path.resolve()) + return tuple(sorted(results)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a533aca4..3f6de466 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -8,6 +8,7 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command +from logicytics.file_listing import list_files from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight @@ -58,6 +59,18 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_file_listing_filters_and_normalizes_files(self) -> None: + """Recursive file discovery must filter extensions and excluded directories.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "collectors").mkdir() + (root / "collectors" / "valid.py").write_text("", encoding="utf-8") + (root / "collectors" / "ignore.txt").write_text("", encoding="utf-8") + (root / ".venv").mkdir() + (root / ".venv" / "hidden.py").write_text("", encoding="utf-8") + files = list_files(root, extensions=(".py",), excluded_directories=(".venv",)) + self.assertEqual((root / "collectors" / "valid.py",), files) + def test_command_runner_captures_output_and_parses_structured_levels(self) -> None: """Core command execution must avoid a shell and preserve structured output.""" result = run_command(("python", "-c", "print('INFO: collected'); print('ordinary')")) From c2089c45df4d135f9c647aec079f608441c12cc3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:07:34 +0300 Subject: [PATCH 067/316] feat: add structured timing decorator --- logicytics/logging.py | 23 +++++++++++++++++++++++ tests/test_core_functionality.py | 13 +++++++++++++ 2 files changed, 36 insertions(+) diff --git a/logicytics/logging.py b/logicytics/logging.py index 5c21f76c..928caf72 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -5,10 +5,16 @@ import json from datetime import datetime, timezone from pathlib import Path +from time import perf_counter +from typing import Callable, ParamSpec, TypeVar from logicytics.contracts import EventLogger +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + class FileEventLogger(EventLogger): """Append JSONL events to a single run- or collector-owned log file.""" @@ -32,3 +38,20 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: payload["fields"] = fields with self.path.open("a", encoding="utf-8") as stream: stream.write(json.dumps(payload, sort_keys=True) + "\n") + + +def timed(logger: EventLogger, *, level: str = "info") -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: + """Decorate a function so structured start, finish, error, and duration events are written.""" + def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: + def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: + logger.event(level, "function_started", function=function.__qualname__) + started = perf_counter() + try: + value = function(*args, **kwargs) + except Exception as error: + logger.event("error", "function_failed", function=function.__qualname__, duration_seconds=round(perf_counter() - started, 6), error_type=type(error).__name__) + raise + logger.event(level, "function_finished", function=function.__qualname__, duration_seconds=round(perf_counter() - started, 6)) + return value + return wrapped + return decorate diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 3f6de466..3f4fd3fe 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -9,6 +9,7 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.file_listing import list_files +from logicytics.logging import timed from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight @@ -59,6 +60,18 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_timed_decorator_records_function_lifecycle(self) -> None: + """Timing instrumentation must report start and finish through EventLogger.""" + events: list[tuple[str, str, dict[str, object]]] = [] + class Logger: + def event(self, level: str, message: str, **fields: object) -> None: + events.append((level, message, fields)) + @timed(Logger()) + def add(left: int, right: int) -> int: + return left + right + self.assertEqual(3, add(1, 2)) + self.assertEqual(["function_started", "function_finished"], [event[1] for event in events]) + def test_file_listing_filters_and_normalizes_files(self) -> None: """Recursive file discovery must filter extensions and excluded directories.""" with tempfile.TemporaryDirectory() as temporary: From 4ae15a0460bb3ba7ba1ee79f00139ace499234cf Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:08:32 +0300 Subject: [PATCH 068/316] feat: add logged exception helper --- logicytics/logging.py | 6 ++++++ tests/test_core_functionality.py | 13 ++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/logicytics/logging.py b/logicytics/logging.py index 928caf72..9fa82c58 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -55,3 +55,9 @@ def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: return value return wrapped return decorate + + +def raise_logged(logger: EventLogger, exception_type: type[Exception], message: str, **fields: int | float | str) -> None: + """Record a structured exception event, then raise the requested exception type.""" + logger.event("exception", message, exception_type=exception_type.__name__, **fields) + raise exception_type(message) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 3f4fd3fe..3786a5b4 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -9,7 +9,7 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.file_listing import list_files -from logicytics.logging import timed +from logicytics.logging import raise_logged, timed from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight @@ -60,6 +60,17 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_exception_helper_logs_before_raising(self) -> None: + """Exception helpers must preserve the requested exception type and context.""" + events: list[tuple[str, str, dict[str, object]]] = [] + class Logger: + def event(self, level: str, message: str, **fields: object) -> None: + events.append((level, message, fields)) + with self.assertRaises(ValueError): + raise_logged(Logger(), ValueError, "invalid setting", setting="workers") + self.assertEqual("exception", events[0][0]) + self.assertEqual("ValueError", events[0][2]["exception_type"]) + def test_timed_decorator_records_function_lifecycle(self) -> None: """Timing instrumentation must report start and finish through EventLogger.""" events: list[tuple[str, str, dict[str, object]]] = [] From e25cb41e05c49c98a38e11f22a1861bb5a6e353f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:09:47 +0300 Subject: [PATCH 069/316] feat: add deprecation logging decorator --- logicytics/logging.py | 14 ++++++++++++++ tests/test_core_functionality.py | 15 ++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/logicytics/logging.py b/logicytics/logging.py index 9fa82c58..1a17105f 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import traceback from datetime import datetime, timezone from pathlib import Path from time import perf_counter @@ -61,3 +62,16 @@ def raise_logged(logger: EventLogger, exception_type: type[Exception], message: """Record a structured exception event, then raise the requested exception type.""" logger.event("exception", message, exception_type=exception_type.__name__, **fields) raise exception_type(message) + + +def deprecated(logger: EventLogger, *, removal_version: str, reason: str, include_stack: bool = False) -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: + """Decorate a function so each invocation emits a structured deprecation warning.""" + def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: + def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: + fields: dict[str, str] = {"function": function.__qualname__, "removal_version": removal_version, "reason": reason} + if include_stack: + fields["stack"] = "".join(traceback.format_stack(limit=8)) + logger.event("warning", "function_deprecated", **fields) + return function(*args, **kwargs) + return wrapped + return decorate diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 3786a5b4..26670afa 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -9,7 +9,7 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.file_listing import list_files -from logicytics.logging import raise_logged, timed +from logicytics.logging import deprecated, raise_logged, timed from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight @@ -60,6 +60,19 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_deprecation_decorator_logs_removal_context(self) -> None: + """Deprecated functions must preserve behavior while reporting removal context.""" + events: list[tuple[str, str, dict[str, object]]] = [] + class Logger: + def event(self, level: str, message: str, **fields: object) -> None: + events.append((level, message, fields)) + @deprecated(Logger(), removal_version="5.0", reason="replacement exists") + def old() -> str: + return "still works" + self.assertEqual("still works", old()) + self.assertEqual("function_deprecated", events[0][1]) + self.assertEqual("5.0", events[0][2]["removal_version"]) + def test_exception_helper_logs_before_raising(self) -> None: """Exception helpers must preserve the requested exception type and context.""" events: list[tuple[str, str, dict[str, object]]] = [] From a549a017d3be899f348fab5c734e4b6a03a00997 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:12:18 +0300 Subject: [PATCH 070/316] feat: add process memory map collector --- core/process/memory_map.py | 86 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 87 insertions(+) create mode 100644 core/process/memory_map.py diff --git a/core/process/memory_map.py b/core/process/memory_map.py new file mode 100644 index 00000000..bce80f23 --- /dev/null +++ b/core/process/memory_map.py @@ -0,0 +1,86 @@ +"""Export readable memory-region metadata for the isolated collector process.""" + +from __future__ import annotations + +import ctypes +import json +from ctypes import wintypes + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +class _MemoryBasicInformation(ctypes.Structure): + _fields_ = [("BaseAddress", wintypes.LPVOID), ("AllocationBase", wintypes.LPVOID), ("AllocationProtect", wintypes.DWORD), ("PartitionId", wintypes.WORD), ("RegionSize", ctypes.c_size_t), ("State", wintypes.DWORD), ("Protect", wintypes.DWORD), ("Type", wintypes.DWORD)] + + +class _ProcessMemoryCounters(ctypes.Structure): + _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + + +def _permissions(protection: int) -> str: + """Return a readable Windows page-protection summary.""" + values = {0x02: "read", 0x04: "read_write", 0x08: "write_copy", 0x20: "execute_read", 0x40: "execute_read_write", 0x80: "execute_write_copy"} + return values.get(protection & 0xFF, "unreadable") + + +class MemoryMapCollector(CoreCollector): + """Capture bounded virtual-memory metadata from the isolated worker process.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the bounded memory-region JSON artifact contract.""" + return CollectorMetadata( + id="core.process.memory_map", name="Process memory map", version="4.0.0", specialty=Specialty.PROCESS, + description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.", author="Logicytics", + supported_platforms=("win32",), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=64 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Validate cancellation state and bounded region settings.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + try: + maximum = int(context.settings.get("max_regions", 5_000)) + except (TypeError, ValueError): + return ValidationResult(False, reasons=("max_regions must be an integer",)) + return ValidationResult(1 <= maximum <= 100_000, reasons=() if 1 <= maximum <= 100_000 else ("max_regions must be 1-100000",)) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query readable local memory regions and write metadata-only JSON evidence.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory-map collection") + maximum = int(context.settings.get("max_regions", 5_000)) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + psapi = ctypes.WinDLL("psapi", use_last_error=True) + process = kernel32.GetCurrentProcess() + counters = _ProcessMemoryCounters() + psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), ctypes.sizeof(counters)) + memory = _MemoryBasicInformation() + address = 0 + regions: list[dict[str, object]] = [] + context.report_progress("memory_map_started", max_regions=maximum) + while len(regions) < maximum: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during memory-map collection") + queried = kernel32.VirtualQuery(ctypes.c_void_p(address), ctypes.byref(memory), ctypes.sizeof(memory)) + if not queried or memory.RegionSize == 0: + break + base = ctypes.cast(memory.BaseAddress, ctypes.c_void_p).value or address + readable = memory.State == 0x1000 and not (memory.Protect & 0x101) + if readable: + mapped = ctypes.create_unicode_buffer(32_768) + mapped_length = psapi.GetMappedFileNameW(process, ctypes.c_void_p(base), mapped, len(mapped)) + regions.append({"index": len(regions), "address": f"0x{base:016X}", "size_bytes": memory.RegionSize, "rss_bytes": counters.WorkingSetSize, "permissions": _permissions(memory.Protect), "mapped_path": mapped.value if mapped_length else None, "state": memory.State, "type": memory.Type}) + next_address = base + memory.RegionSize + if next_address <= address: + break + address = next_address + output = context.workspace / "memory_map.json" + output.write_text(json.dumps({"region_count": len(regions), "regions": regions}, indent=2) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("memory_map_finished", region_count=len(regions), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("process memory map collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because the collector only queries its own process.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 31b9b099..a3731177 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -40,6 +40,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.process.running_processes", "core.process.detailed_processes", "core.process.process_memory", + "core.process.memory_map", "core.registry.installed_applications", "core.registry.hklm_backup", "core.registry.startup_applications", From 7180a0212a894c41aa77c82346e3715521c949ba Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:14:34 +0300 Subject: [PATCH 071/316] feat: add memory map output safeguards --- core/process/memory_map.py | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/core/process/memory_map.py b/core/process/memory_map.py index bce80f23..ae7def9a 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -4,7 +4,9 @@ import ctypes import json +import shutil from ctypes import wintypes +from pathlib import Path from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus @@ -42,15 +44,27 @@ def validate(self, context: CollectorContext) -> ValidationResult: return ValidationResult(False, reasons=("run cancellation was requested",)) try: maximum = int(context.settings.get("max_regions", 5_000)) + output_limit = int(context.settings.get("output_limit_bytes", 64 * 1024 * 1024)) + safety_margin = int(context.settings.get("disk_safety_margin_bytes", 100 * 1024 * 1024)) except (TypeError, ValueError): - return ValidationResult(False, reasons=("max_regions must be an integer",)) - return ValidationResult(1 <= maximum <= 100_000, reasons=() if 1 <= maximum <= 100_000 else ("max_regions must be 1-100000",)) + return ValidationResult(False, reasons=("memory-map limits must be integers",)) + if not 1 <= maximum <= 100_000 or not 1_024 <= output_limit <= 64 * 1024 * 1024 or safety_margin < 0: + return ValidationResult(False, reasons=("invalid memory-map region, output, or safety limits",)) + return ValidationResult(True) def collect(self, context: CollectorContext) -> CollectorResult: """Query readable local memory regions and write metadata-only JSON evidence.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory-map collection") maximum = int(context.settings.get("max_regions", 5_000)) + output_limit = int(context.settings.get("output_limit_bytes", 64 * 1024 * 1024)) + safety_margin = int(context.settings.get("disk_safety_margin_bytes", 100 * 1024 * 1024)) + configured_directory = Path(str(context.settings.get("dump_directory", "memory_maps"))) + output_directory = (context.workspace / configured_directory).resolve() + try: + output_directory.relative_to(context.workspace.resolve()) + except ValueError: + return CollectorResult(CollectorStatus.FAILED, "memory-map dump_directory must stay inside the collector workspace") kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) psapi = ctypes.WinDLL("psapi", use_last_error=True) process = kernel32.GetCurrentProcess() @@ -76,11 +90,22 @@ def collect(self, context: CollectorContext) -> CollectorResult: if next_address <= address: break address = next_address - output = context.workspace / "memory_map.json" - output.write_text(json.dumps({"region_count": len(regions), "regions": regions}, indent=2) + "\n", encoding="utf-8") + truncated = False + while True: + serialized = json.dumps({"region_count": len(regions), "truncated": truncated, "regions": regions}, indent=2) + "\n" + if len(serialized.encode("utf-8")) <= output_limit or not regions: + break + regions.pop() + truncated = True + if shutil.disk_usage(context.workspace).free < len(serialized.encode("utf-8")) + safety_margin: + return CollectorResult(CollectorStatus.SKIPPED, "insufficient free disk space after configured memory-map safety margin") + output_directory.mkdir(parents=True, exist_ok=True) + output = output_directory / "memory_map.json" + output.write_text(serialized, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("memory_map_finished", region_count=len(regions), bytes_written=artifact.size_bytes) - return CollectorResult.succeeded("process memory map collected", (artifact,)) + context.report_progress("memory_map_finished", region_count=len(regions), truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + summary = "process memory map collected" if not truncated else "process memory map collected with configured output truncation" + return CollectorResult.succeeded(summary, (artifact,)) def cleanup(self, context: CollectorContext) -> None: """Release no resources because the collector only queries its own process.""" From 2745f465c2df60b9077f042b6973716aaa174931 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:15:59 +0300 Subject: [PATCH 072/316] feat: add performance check mode --- logicytics/cli.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index f7909863..7c27868c 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -25,7 +25,7 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: include=tuple(arguments.include), exclude=tuple(arguments.exclude), enable_plugins=arguments.plugins, - max_workers=arguments.workers or default_workers, + max_workers=1 if getattr(arguments, "performance_check", False) else arguments.workers or default_workers, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), ) @@ -55,6 +55,11 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Confirm you are authorized to collect the selected evidence.", ) + subparser.add_argument( + "--performance-check", + action="store_true", + help="Run collectors sequentially and write a per-collector duration report.", + ) return parser @@ -80,6 +85,23 @@ def main(argv: list[str] | None = None) -> int: print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) return 0 outcome = RunSupervisor(root, configuration).run(plan) + if arguments.performance_check: + performance_path = outcome.run_directory / "logs" / "performance.json" + performance_path.write_text( + json.dumps( + { + "run_id": outcome.manifest.run_id, + "collectors": [ + {"id": record.id, "status": record.status, "duration_seconds": record.duration_seconds} + for record in outcome.manifest.collectors + ], + }, + indent=2, + sort_keys=True, + ) + "\n", + encoding="utf-8", + ) + print(f"Performance: {performance_path}") if configuration.runtime.package_completed_runs: package_path, hash_path = package_run(outcome) print(f"Package: {package_path}\nSHA-256: {hash_path}") From 72ca3e5bc67c2e25ac203084ef27387ee6611d77 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:17:42 +0300 Subject: [PATCH 073/316] feat: add profile mode aliases --- logicytics/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index 7c27868c..f8f80725 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -20,12 +20,13 @@ def _project_root() -> Path: def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: + profile = "minimal" if getattr(arguments, "minimal", False) else "deep" if getattr(arguments, "depth", False) else "standard" if getattr(arguments, "default_mode", False) or getattr(arguments, "threaded", False) else arguments.profile return RunRequest( - profile=arguments.profile, + profile=profile, include=tuple(arguments.include), exclude=tuple(arguments.exclude), enable_plugins=arguments.plugins, - max_workers=1 if getattr(arguments, "performance_check", False) else arguments.workers or default_workers, + max_workers=1 if getattr(arguments, "performance_check", False) or getattr(arguments, "default_mode", False) else arguments.workers or default_workers, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), ) @@ -50,6 +51,11 @@ def _parser() -> argparse.ArgumentParser: help="Approve an access capability requested by the selected collectors.", ) if command == "run": + mode = subparser.add_mutually_exclusive_group() + mode.add_argument("--default", dest="default_mode", action="store_true", help="Run the standard built-in profile.") + mode.add_argument("--threaded", action="store_true", help="Run the standard built-in profile with configured parallel workers.") + mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") + mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") subparser.add_argument( "--acknowledge-authorization", action="store_true", From 06cf36d29eace53a6f58d53e5b89e53ca2d282a6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:19:27 +0300 Subject: [PATCH 074/316] feat: allow custom plugin specialties --- logicytics/contracts.py | 16 ++++++++++++---- logicytics/discovery.py | 2 +- tests/test_core_functionality.py | 13 ++++++++++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 1718b913..b959ac6f 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -6,9 +6,11 @@ from dataclasses import asdict, dataclass, field from enum import StrEnum from pathlib import Path +import re from typing import Any, Mapping CONTRACT_VERSION = "4.0" +_CUSTOM_SPECIALTY = re.compile(r"^[a-z][a-z0-9_]{1,63}$") class CollectorKind(StrEnum): @@ -85,7 +87,7 @@ class CollectorMetadata: id: str name: str version: str - specialty: Specialty + specialty: Specialty | str description: str author: str supported_platforms: tuple[str, ...] = ("win32",) @@ -101,15 +103,21 @@ class CollectorMetadata: def to_dict(self) -> dict[str, Any]: """Return JSON-safe metadata.""" data = asdict(self) - data["specialty"] = self.specialty.value + data["specialty"] = self.specialty.value if isinstance(self.specialty, Specialty) else self.specialty data["capabilities"] = [capability.value for capability in self.capabilities] return data @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> "CollectorMetadata": + def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = False) -> "CollectorMetadata": """Build metadata returned by an isolated validation worker.""" values = dict(data) - values["specialty"] = Specialty(values["specialty"]) + specialty = values["specialty"] + try: + values["specialty"] = Specialty(specialty) + except ValueError: + if not allow_custom_specialty or not isinstance(specialty, str) or not _CUSTOM_SPECIALTY.fullmatch(specialty): + raise ValueError("collector specialty is unsupported") + values["specialty"] = specialty values["capabilities"] = tuple(Capability(capability) for capability in values.get("capabilities", ())) for field_name in ( "supported_platforms", diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 8126ddfd..84788129 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -229,7 +229,7 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: return try: payload = json.loads(completed.stdout) - metadata = CollectorMetadata.from_dict(payload["metadata"]) + metadata = CollectorMetadata.from_dict(payload["metadata"], allow_custom_specialty=candidate.kind is CollectorKind.PLUGIN) except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: candidate.runtime_error = f"invalid validation response: {error}" return diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 26670afa..56c3d6ad 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -11,7 +11,7 @@ from logicytics.file_listing import list_files from logicytics.logging import deprecated, raise_logged, timed from logicytics.configuration import default_config, load_config -from logicytics.contracts import Capability, RunRequest +from logicytics.contracts import Capability, CollectorMetadata, RunRequest, Specialty from logicytics.discovery import preflight from logicytics.errors import ArtifactError, PlanError, PreflightError from logicytics.packaging import package_run @@ -60,6 +60,17 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_custom_specialty_is_plugin_only(self) -> None: + """Plugins may extend specialties, but core metadata remains on the closed set.""" + metadata = CollectorMetadata( + id="plugin.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, + description="Test metadata.", author="Test", + ).to_dict() + metadata["specialty"] = "evidence_graph" + self.assertEqual("evidence_graph", CollectorMetadata.from_dict(metadata, allow_custom_specialty=True).specialty) + with self.assertRaises(ValueError): + CollectorMetadata.from_dict(metadata) + def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, object]]] = [] From 0b9a94f0bc8c4b15c26c0cdf3db21ded7b1364a6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:20:35 +0300 Subject: [PATCH 075/316] feat: add environment preflight checks --- logicytics/cli.py | 2 ++ logicytics/environment.py | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 logicytics/environment.py diff --git a/logicytics/cli.py b/logicytics/cli.py index f8f80725..f602ba15 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -10,6 +10,7 @@ from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight from logicytics.errors import LogicyticsError +from logicytics.environment import inspect_environment from logicytics.packaging import package_run from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor @@ -78,6 +79,7 @@ def main(argv: list[str] | None = None) -> int: report = preflight(root) if arguments.command == "preflight": payload = { + "environment": inspect_environment().to_dict(), "valid": [candidate.metadata.id for candidate in report.valid if candidate.metadata], "invalid": [ {"path": str(candidate.path), "errors": candidate.static_errors or [candidate.runtime_error]} diff --git a/logicytics/environment.py b/logicytics/environment.py new file mode 100644 index 00000000..16cf87f7 --- /dev/null +++ b/logicytics/environment.py @@ -0,0 +1,44 @@ +"""Read-only Windows environment checks used before v4 planning and execution.""" + +from __future__ import annotations + +import ctypes +import subprocess +from dataclasses import asdict, dataclass +from shutil import which + + +@dataclass(frozen=True, slots=True) +class EnvironmentReport: + """Local privilege, UAC, and PowerShell-policy state.""" + + is_administrator: bool | None + uac_enabled: bool | None + powershell_execution_policy: str | None + + def to_dict(self) -> dict[str, bool | str | None]: + """Return a JSON-safe environment report.""" + return asdict(self) + + +def inspect_environment() -> EnvironmentReport: + """Read local environment state without changing policy or elevation.""" + try: + is_administrator = bool(ctypes.windll.shell32.IsUserAnAdmin()) + except (AttributeError, OSError): + is_administrator = None + try: + import winreg + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System") as key: + uac_enabled = bool(winreg.QueryValueEx(key, "EnableLUA")[0]) + except (ImportError, OSError): + uac_enabled = None + policy = None + if which("powershell") is not None: + try: + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], capture_output=True, check=False, text=True, timeout=15) + if completed.returncode == 0: + policy = completed.stdout.strip() or None + except OSError: + pass + return EnvironmentReport(is_administrator, uac_enabled, policy) From 877cc9dd149a69f879a4d53e7e7bfe0d62026919 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:22:10 +0300 Subject: [PATCH 076/316] feat: add Sysinternals archive lifecycle --- logicytics/cli.py | 2 ++ logicytics/sysinternals.py | 45 ++++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 15 +++++++++++ 3 files changed, 62 insertions(+) create mode 100644 logicytics/sysinternals.py diff --git a/logicytics/cli.py b/logicytics/cli.py index f602ba15..56b048f0 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -14,6 +14,7 @@ from logicytics.packaging import package_run from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from logicytics.sysinternals import ensure_sysinternals def _project_root() -> Path: @@ -80,6 +81,7 @@ def main(argv: list[str] | None = None) -> int: if arguments.command == "preflight": payload = { "environment": inspect_environment().to_dict(), + "sysinternals": ensure_sysinternals(root).to_dict(), "valid": [candidate.metadata.id for candidate in report.valid if candidate.metadata], "invalid": [ {"path": str(candidate.path), "errors": candidate.static_errors or [candidate.runtime_error]} diff --git a/logicytics/sysinternals.py b/logicytics/sysinternals.py new file mode 100644 index 00000000..8bd85aa4 --- /dev/null +++ b/logicytics/sysinternals.py @@ -0,0 +1,45 @@ +"""Safe local Sysinternals archive discovery and extraction for v4.""" + +from __future__ import annotations + +import zipfile +from dataclasses import asdict, dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class SysinternalsState: + """Archive, ignore-marker, and extraction state for the local project.""" + + status: str + archive: Path + extraction_directory: Path + + def to_dict(self) -> dict[str, str]: + """Return a JSON-safe state summary.""" + data = asdict(self) + data["archive"] = str(self.archive) + data["extraction_directory"] = str(self.extraction_directory) + return data + + +def ensure_sysinternals(project_root: Path) -> SysinternalsState: + """Safely extract a bundled archive unless an explicit local marker opts out.""" + archive = project_root / "SysinternalsSuite.zip" + extraction_directory = project_root / "tools" / "sysinternals" + if (project_root / ".ignore-sysinternals").is_file(): + return SysinternalsState("ignored", archive, extraction_directory) + if extraction_directory.is_dir(): + return SysinternalsState("extracted", archive, extraction_directory) + if not archive.is_file(): + return SysinternalsState("missing", archive, extraction_directory) + extraction_directory.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + destination = (extraction_directory / member.filename).resolve() + try: + destination.relative_to(extraction_directory.resolve()) + except ValueError as error: + raise ValueError("Sysinternals archive contains an unsafe path") from error + bundle.extractall(extraction_directory) + return SysinternalsState("extracted", archive, extraction_directory) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 56c3d6ad..ea8758b2 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -4,12 +4,14 @@ import tempfile import unittest +import zipfile from pathlib import Path from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.file_listing import list_files from logicytics.logging import deprecated, raise_logged, timed +from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, CollectorMetadata, RunRequest, Specialty from logicytics.discovery import preflight @@ -60,6 +62,19 @@ def cleanup(self, context: CollectorContext) -> None: class CoreFunctionalityTests(unittest.TestCase): + def test_sysinternals_archive_lifecycle_honors_ignore_and_extracts_safely(self) -> None: + """The local bundle must honor opt-out and extract only within its target directory.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / ".ignore-sysinternals").write_text("", encoding="utf-8") + self.assertEqual("ignored", ensure_sysinternals(root).status) + (root / ".ignore-sysinternals").unlink() + with zipfile.ZipFile(root / "SysinternalsSuite.zip", "w") as archive: + archive.writestr("PsInfo.exe", "fixture") + state = ensure_sysinternals(root) + self.assertEqual("extracted", state.status) + self.assertTrue((state.extraction_directory / "PsInfo.exe").is_file()) + def test_custom_specialty_is_plugin_only(self) -> None: """Plugins may extend specialties, but core metadata remains on the closed set.""" metadata = CollectorMetadata( From 171c633e9025748f8fa0f463d48061f94eb11baa Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:23:44 +0300 Subject: [PATCH 077/316] feat: add debug diagnostics action --- logicytics/cli.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index 56b048f0..d1eb624a 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -3,7 +3,11 @@ from __future__ import annotations import argparse +import importlib.util import json +import os +import platform +import sys from pathlib import Path from logicytics.configuration import load_config @@ -38,7 +42,7 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") subcommands = parser.add_subparsers(dest="command", required=True) - for command in ("preflight", "plan", "run"): + for command in ("preflight", "debug", "plan", "run"): subparser = subcommands.add_parser(command) subparser.add_argument("--profile", default="standard") subparser.add_argument("--include", action="append", default=[]) @@ -90,6 +94,16 @@ def main(argv: list[str] | None = None) -> int: } print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if not report.invalid else 2 + if arguments.command == "debug": + payload = { + "configuration": configuration.to_manifest_dict(), + "environment": inspect_environment().to_dict(), + "python": {"executable": sys.executable, "implementation": platform.python_implementation(), "version": platform.python_version(), "prefix": sys.prefix, "virtual_environment": sys.prefix != sys.base_prefix, "psutil_available": importlib.util.find_spec("psutil") is not None, "cpu_count": os.cpu_count()}, + "sysinternals": ensure_sysinternals(root).to_dict(), + "preflight": {"valid_collectors": len(report.valid), "invalid_collectors": len(report.invalid)}, + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if not report.invalid else 2 plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) if arguments.command == "plan": print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) From bb63cc94e2638cef814a346580b0659ef97f0a09 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 02:25:14 +0300 Subject: [PATCH 078/316] feat: add explicit update action --- .gitignore | 1 + logicytics/cli.py | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index a25f2a40..9e544d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -328,4 +328,5 @@ $RECYCLE.BIN/ /CODE/SysInternal_Suite/pslist.exe /CODE/SysInternal_Suite/PsLoggedon.exe /CODE/SysInternal_Suite/psloglist.exe +TODO.md .idea/ \ No newline at end of file diff --git a/logicytics/cli.py b/logicytics/cli.py index d1eb624a..1fa796e5 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -7,6 +7,7 @@ import json import os import platform +import subprocess import sys from pathlib import Path @@ -42,7 +43,7 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") subcommands = parser.add_subparsers(dest="command", required=True) - for command in ("preflight", "debug", "plan", "run"): + for command in ("preflight", "debug", "update", "plan", "run"): subparser = subcommands.add_parser(command) subparser.add_argument("--profile", default="standard") subparser.add_argument("--include", action="append", default=[]) @@ -67,6 +68,8 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Confirm you are authorized to collect the selected evidence.", ) + if command == "update": + subparser.add_argument("--apply", action="store_true", help="Explicitly run git pull after repository checks.") subparser.add_argument( "--performance-check", action="store_true", @@ -104,6 +107,18 @@ def main(argv: list[str] | None = None) -> int: } print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if not report.invalid else 2 + if arguments.command == "update": + git = subprocess.run(["git", "--version"], capture_output=True, check=False, text=True) + is_repository = (root / ".git").exists() + payload = {"git_available": git.returncode == 0, "git_version": git.stdout.strip() or None, "is_repository": is_repository, "applied": False} + if arguments.apply: + if git.returncode != 0 or not is_repository: + print(json.dumps(payload, indent=2, sort_keys=True)) + return 2 + pulled = subprocess.run(["git", "pull"], cwd=root, capture_output=True, check=False, text=True) + payload.update({"applied": True, "returncode": pulled.returncode, "stdout": pulled.stdout, "stderr": pulled.stderr}) + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if not arguments.apply or payload.get("returncode") == 0 else 1 plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) if arguments.command == "plan": print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) From fbfcb795397fe118dc834fd1ec8dac6b469e9012 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 16:12:31 +0300 Subject: [PATCH 079/316] docs: updated codeowners to remove stale entries; updated plans to empty (currently working on v4); updated security for supported versions --- .github/CODEOWNERS | 6 +----- PLANS.md | 10 +++------- SECURITY.md | 46 +++++++++++++++++++++++----------------------- 3 files changed, 27 insertions(+), 35 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 05540e45..27373b05 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1 @@ -* @DefinetlyNotAI -wifi_stealer.py @ski-sketch -packet_sniffer.py @ski-sketch -bluetooth_details.py @ski-sketch -bluetooth_logger.py @ski-sketch \ No newline at end of file +* @DefinetlyNotAI \ No newline at end of file diff --git a/PLANS.md b/PLANS.md index 221aa875..9d5e2e2d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -6,10 +6,6 @@ > - ❌ ➡️ Might be done, Not sure yet > - ✅ ➡️ Will be done, 100% sure -| Task | Version | Might or Will be done? | -|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|------------------------| -| Update to model 4n2 of vulnscan | v3.6.1 | ✅ | -| Merge `sensitive data miner` with `vulnscan` to be 1 tool | v4.0.0 | ❌ | -| Remake Logicytics End-Execution cycle, where files created must go in `temp/` directory, and zipper takes it from there only, simplifying any code logic with this as well | v4.0.0 | ✅ | -| Replace Logger.py with Util that contains (tprint), also implement the ExceptionHandler and UpdateManager from Util | v4.0.0 | ✅ | -| Make WIKI in the git repo, with a yaml file that updates it to the default github wiki | v4.0.0 | ✅ | +| Task | Version | To Complete? | +|--------------|---------|--------------| +| No plans yet | vX.Y.Z | ✅ | diff --git a/SECURITY.md b/SECURITY.md index 110e6ab6..c66701d3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,35 +6,35 @@ This section outlines the versions of our project that are currently supported w | Version | Supported | Major Release Date | |---------|-----------|--------------------| -| 3.6.x | ✅ | July 26, 2025 | -| 3.5.x | ✅ | July 26, 2025 | -| 3.4.x | ✖️ | January 3, 2025 | -| 3.3.x | ✖️ | January 3, 2025 | -| 3.2.x | ✖️ | Dec 19, 2024 | -| 3.1.x | ✖️ | Dec 11, 2024 | -| 3.0.x | ❌ | Dec 6, 2024 | -| 2.5.x | ❌ | Nov 25, 2024 | -| 2.4.x | ❌ | Nov 12, 2024 | -| 2.3.x | ❌ | Sep 21, 2024 | -| 2.2.x | ❌ | Sep 9, 2024 | -| 2.1.x | ❌ | Aug 29, 2024 | -| 2.0.x | ❌ | Aug 25, 2024 | -| 1.6.x | ❌ | Jun 18, 2024 | -| 1.5.x | ❌ | Jun 10, 2024 | -| 1.4.x | ❌ | May 30, 2024 | -| 1.3.x | ❌ | May 21, 2024 | -| 1.2.x | ❌ | May 16, 2024 | -| 1.1.x | ❌ | May 10, 2024 | -| 1.0.x | ❌ | May 4, 2024 | +| 4.0.x | ✅ | To be released | +| 3.6.x | ✖️ | July 26, 2025 | +| 3.5.x | ✖️ | July 26, 2025 | +| 3.4.x | ❌ | January 3, 2025 | +| 3.3.x | ❌ | January 3, 2025 | +| 3.2.x | ❌ | Dec 19, 2024 | +| 3.1.x | ❌ | Dec 11, 2024 | +| 3.0.x | ❌ | Dec 6, 2024 | +| 2.5.x | ❌ | Nov 25, 2024 | +| 2.4.x | ❌ | Nov 12, 2024 | +| 2.3.x | ❌ | Sep 21, 2024 | +| 2.2.x | ❌ | Sep 9, 2024 | +| 2.1.x | ❌ | Aug 29, 2024 | +| 2.0.x | ❌ | Aug 25, 2024 | +| 1.6.x | ❌ | Jun 18, 2024 | +| 1.5.x | ❌ | Jun 10, 2024 | +| 1.4.x | ❌ | May 30, 2024 | +| 1.3.x | ❌ | May 21, 2024 | +| 1.2.x | ❌ | May 16, 2024 | +| 1.1.x | ❌ | May 10, 2024 | +| 1.0.x | ❌ | May 4, 2024 | ### Key: | Key | Desc | |-----|-----------------------------------------------------| -| ✅ | Supported for all security updates | -| ⚠️ | Supported, but will leave support next major update | +| ✅ | Supported for all security updates | | ✖️ | Only for major security issues (CVSS 8.0+) | -| ❌ | No longer supported for any security updates | +| ❌ | No longer supported for any security updates | ## Reporting a Vulnerability From 2b57185a58b84dc46f47f6ba5eb8f75c3a3b6918 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 17 Aug 2026 16:13:50 +0300 Subject: [PATCH 080/316] chore: reformat code --- core/bluetooth/bluetooth_addresses.py | 28 ++++++++++------ core/bluetooth/bluetooth_history.py | 21 ++++++++---- core/bluetooth/paired_devices.py | 3 +- core/browser/browser_data_backup.py | 31 ++++++++++++------ core/diagnostics/sysinternals_report.py | 19 +++++++---- core/encryption/bitlocker_status.py | 9 +++--- core/encryption/bitlocker_volumes.py | 6 ++-- core/event_log/application_events.py | 7 ++-- core/event_log/security_events.py | 18 +++++++---- core/filesystem/sensitive_file_inventory.py | 30 +++++++++++------ core/filesystem/system_drive_listing.py | 18 +++++++---- core/filesystem/system_drive_tree.py | 12 ++++--- core/hardware/battery_status.py | 15 ++++++--- core/hardware/windows_features.py | 3 +- core/media/media_backup.py | 13 +++++--- core/network/active_connections.py | 6 ++-- core/network/adapter_statistics.py | 6 ++-- core/network/bandwidth_sample.py | 35 ++++++++++++++------ core/network/connection_processes.py | 15 ++++++--- core/network/dns_cache.py | 9 ++++-- core/network/firewall_profiles.py | 15 ++++++--- core/network/network_adapters.py | 2 +- core/network/network_interfaces.py | 9 ++++-- core/packet/connection_graph.py | 15 ++++++--- core/packet/packet_capture.py | 27 ++++++++++------ core/process/detailed_processes.py | 6 ++-- core/process/memory_map.py | 36 +++++++++++++++------ core/process/process_memory.py | 9 ++++-- core/registry/hklm_backup.py | 9 +++--- core/registry/installed_applications.py | 22 +++++++++---- core/registry/startup_applications.py | 13 +++++--- core/ssh/ssh_backup.py | 7 ++-- core/storage/logical_drives.py | 8 ++--- core/storage/mounted_volumes.py | 6 ++-- core/storage/physical_disks.py | 6 ++-- core/storage/volume_details.py | 16 ++++++--- core/system/environment_posture.py | 7 ++-- core/system/installed_drivers.py | 3 +- core/system/installed_updates.py | 18 +++++++---- core/system/session_snapshot.py | 6 ++-- core/system/system_diagnostics.py | 9 ++++-- core/system/windows_services.py | 15 ++++++--- core/system/windows_system_data_backup.py | 19 +++++++---- core/usb/usb_storage_inventory.py | 3 +- core/wireless/wifi_interfaces.py | 9 +++--- core/wireless/wifi_profile_keys.py | 15 +++++---- core/wireless/wifi_profiles.py | 6 ++-- logicytics/artifacts.py | 10 +++--- logicytics/cli.py | 30 +++++++++++------ logicytics/command_runner.py | 3 +- logicytics/configuration.py | 8 ++--- logicytics/contracts.py | 29 +++++++++-------- logicytics/discovery.py | 21 +++++++----- logicytics/environment.py | 7 ++-- logicytics/file_listing.py | 15 +++++---- logicytics/logging.py | 25 ++++++++++---- logicytics/manifest.py | 13 ++++---- logicytics/packaging.py | 1 - logicytics/runtime.py | 23 +++++++------ 59 files changed, 522 insertions(+), 283 deletions(-) diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index 48a55308..e9d77b5c 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -10,6 +10,7 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus + def _is_access_denied(detail: str) -> bool: """Recognize common access-denied wording from PowerShell PnP queries.""" normalized = detail.casefold() @@ -19,7 +20,7 @@ def _is_access_denied(detail: str) -> bool: def _format_address(instance_id: object) -> str | None: """Extract one conventional colon-separated address from a PnP instance identifier.""" match = re.search(r"(? CollectorMetadata: """Declare the subprocess-gated Bluetooth-address JSON artifact contract.""" return CollectorMetadata( - id="core.bluetooth.bluetooth_addresses", name="Bluetooth addresses", version="4.0.0", specialty=Specialty.BLUETOOTH, - description="Exports paired Bluetooth friendly names and address-like identifiers from PnP data.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("wireless_identifiers",), + id="core.bluetooth.bluetooth_addresses", name="Bluetooth addresses", version="4.0.0", + specialty=Specialty.BLUETOOTH, + description="Exports paired Bluetooth friendly names and address-like identifiers from PnP data.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("wireless_identifiers",), default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=256 * 1024, ) @@ -49,24 +53,30 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-address collection") context.report_progress("bluetooth_addresses_started") command = "Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Present | ConvertTo-Json -Depth 2" - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=25) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=25) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Bluetooth PnP access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Bluetooth PnP access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query failed", errors=(detail,)) try: raw = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned invalid JSON", + errors=(str(error),)) devices = raw if isinstance(raw, list) else [raw] if not all(isinstance(device, dict) for device in devices): return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned an unexpected result") - report = [{"friendly_name": device.get("FriendlyName"), "instance_id": device.get("InstanceId"), "address": _format_address(device.get("InstanceId")), "present": device.get("Present")} for device in devices] + report = [{"friendly_name": device.get("FriendlyName"), "instance_id": device.get("InstanceId"), + "address": _format_address(device.get("InstanceId")), "present": device.get("Present")} for device in + devices] output = context.workspace / "bluetooth_addresses.json" output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bluetooth_addresses_finished", device_count=len(report), bytes_written=artifact.size_bytes) + context.report_progress("bluetooth_addresses_finished", device_count=len(report), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("Bluetooth names and address identifiers collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index aec3a61d..02f4c3ec 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -24,10 +24,12 @@ class BluetoothHistoryCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the bounded Bluetooth-history JSON artifact contract.""" return CollectorMetadata( - id="core.bluetooth.bluetooth_history", name="Bluetooth history snapshot", version="4.0.0", specialty=Specialty.BLUETOOTH, + id="core.bluetooth.bluetooth_history", name="Bluetooth history snapshot", version="4.0.0", + specialty=Specialty.BLUETOOTH, description="Exports a timestamped Bluetooth PnP snapshot; retained run packages provide historical evidence.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("device_identifiers",), default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=512 * 1024, + sensitive_data_categories=("device_identifiers",), default_profiles=("deep",), timeout_seconds=45, + maximum_output_bytes=512 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -44,22 +46,27 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-history collection") context.report_progress("bluetooth_history_started") command = "$ErrorActionPreference = 'Stop'; @(Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Status, Problem, Present) | ConvertTo-Json -Depth 3" - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=40) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Bluetooth-history access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Bluetooth-history access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query failed", errors=(detail,)) try: devices = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query returned invalid JSON", errors=(str(error),)) - snapshot = {"collected_at": datetime.now(timezone.utc).isoformat(), "devices": devices if isinstance(devices, list) else [devices]} + return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query returned invalid JSON", + errors=(str(error),)) + snapshot = {"collected_at": datetime.now(timezone.utc).isoformat(), + "devices": devices if isinstance(devices, list) else [devices]} stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") output = context.workspace / f"bluetooth_history_{stamp}.json" output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bluetooth_history_finished", device_count=len(snapshot["devices"]), bytes_written=artifact.size_bytes) + context.report_progress("bluetooth_history_finished", device_count=len(snapshot["devices"]), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("Bluetooth history snapshot collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py index d0ef3ff2..f9693213 100644 --- a/core/bluetooth/paired_devices.py +++ b/core/bluetooth/paired_devices.py @@ -89,7 +89,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "bluetooth_devices.json" output.write_text(json.dumps(devices, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bluetooth_devices_finished", device_count=len(devices), bytes_written=artifact.size_bytes) + context.report_progress("bluetooth_devices_finished", device_count=len(devices), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("Bluetooth device metadata collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index 1a9b421d..29ca1ecf 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -9,8 +9,8 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus - -CHROMIUM_FILES = ("History", "History-journal", "Cookies", "Cookies-journal", "Login Data", "Login Data-journal", "Bookmarks", "Preferences") +CHROMIUM_FILES = ("History", "History-journal", "Cookies", "Cookies-journal", "Login Data", "Login Data-journal", + "Bookmarks", "Preferences") FIREFOX_FILES = ("places.sqlite", "cookies.sqlite", "logins.json", "key4.db", "prefs.js") MAX_FILE_BYTES = 50 * 1024 * 1024 MAX_TOTAL_BYTES = 256 * 1024 * 1024 @@ -38,7 +38,8 @@ class BrowserDataBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent browser-data artifact contract.""" return CollectorMetadata( - id="core.browser.browser_data_backup", name="Browser data backup", version="4.0.0", specialty=Specialty.BROWSER, + id="core.browser.browser_data_backup", name="Browser data backup", version="4.0.0", + specialty=Specialty.BROWSER, description="Copies bounded local profile evidence from Edge, Chrome, Firefox, Opera, and Opera GX.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.BROWSER_DATA, Capability.SENSITIVE_FILES), @@ -58,20 +59,28 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before browser data backup") local = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) roaming = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) - chromium_roots = (("chrome", local / "Google" / "Chrome" / "User Data"), ("edge", local / "Microsoft" / "Edge" / "User Data"), ("opera", roaming / "Opera Software" / "Opera Stable"), ("opera_gx", roaming / "Opera Software" / "Opera GX Stable")) + chromium_roots = (("chrome", local / "Google" / "Chrome" / "User Data"), + ("edge", local / "Microsoft" / "Edge" / "User Data"), + ("opera", roaming / "Opera Software" / "Opera Stable"), + ("opera_gx", roaming / "Opera Software" / "Opera GX Stable")) copied: list[Path] = [] copied_bytes = 0 context.report_progress("browser_data_backup_started") for browser, root in chromium_roots: try: - profile_roots = [root] if browser.startswith("opera") else [path for path in root.iterdir() if path.is_dir() and (path.name == "Default" or path.name.startswith("Profile "))] + profile_roots = [root] if browser.startswith("opera") else [path for path in root.iterdir() if + path.is_dir() and ( + path.name == "Default" or path.name.startswith( + "Profile "))] except OSError: continue for profile in profile_roots: for filename in CHROMIUM_FILES: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") - destination, copied_bytes = _copy_file(profile / filename, context.workspace / "browser_data" / browser / profile.name / filename, copied_bytes) + destination, copied_bytes = _copy_file(profile / filename, + context.workspace / "browser_data" / browser / profile.name / filename, + copied_bytes) if destination is not None: copied.append(destination) firefox_profiles = roaming / "Mozilla" / "Firefox" / "Profiles" @@ -81,13 +90,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: profiles = [] for profile in profiles: for filename in FIREFOX_FILES: - destination, copied_bytes = _copy_file(profile / filename, context.workspace / "browser_data" / "firefox" / profile.name / filename, copied_bytes) + destination, copied_bytes = _copy_file(profile / filename, + context.workspace / "browser_data" / "firefox" / profile.name / filename, + copied_bytes) if destination is not None: copied.append(destination) if not copied: - return CollectorResult(CollectorStatus.SKIPPED, "no supported local browser profile data met the bounded backup policy") + return CollectorResult(CollectorStatus.SKIPPED, + "no supported local browser profile data met the bounded backup policy") artifacts = tuple(context.artifacts.register_file(path) for path in copied) - context.report_progress("browser_data_backup_finished", copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + context.report_progress("browser_data_backup_finished", copied_files=len(artifacts), + bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("browser data backup collected", artifacts) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py index af0e8488..17be6a2e 100644 --- a/core/diagnostics/sysinternals_report.py +++ b/core/diagnostics/sysinternals_report.py @@ -9,7 +9,6 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus - TOOLS = ("psfile", "psgetsid", "psinfo", "pslist", "psloggedon", "psloglist") MAX_OUTPUT_CHARS = 512_000 @@ -21,9 +20,12 @@ class SysinternalsReportCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Sysinternals report artifact contract.""" return CollectorMetadata( - id="core.diagnostics.sysinternals_report", name="Sysinternals report", version="4.0.0", specialty=Specialty.DIAGNOSTICS, - description="Reports supported Sysinternals binary/archive state and consolidates available tool output.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_diagnostics",), + id="core.diagnostics.sysinternals_report", name="Sysinternals report", version="4.0.0", + specialty=Specialty.DIAGNOSTICS, + description="Reports supported Sysinternals binary/archive state and consolidates available tool output.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_diagnostics",), default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=512 * 1024, ) @@ -38,7 +40,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Sysinternals collection") project_root = Path(__file__).resolve().parents[2] - search_roots = (project_root / "sysinternals", project_root / "tools" / "sysinternals", Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Sysinternals") + search_roots = (project_root / "sysinternals", project_root / "tools" / "sysinternals", + Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Sysinternals") archive_paths = tuple(root.with_suffix(".zip") for root in search_roots) sections: list[str] = ["Sysinternals report", ""] context.report_progress("sysinternals_report_started") @@ -46,7 +49,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: binary = next((root / f"{tool}.exe" for root in search_roots if (root / f"{tool}.exe").is_file()), None) sections.append(f"## {tool}") if binary is None: - archive_state = "archive available" if any(path.is_file() for path in archive_paths) else "binary and archive missing" + archive_state = "archive available" if any( + path.is_file() for path in archive_paths) else "binary and archive missing" sections.extend((f"status: {archive_state}", "")) continue try: @@ -54,7 +58,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: except OSError as error: sections.extend((f"status: execution error: {error}", "")) continue - output = (completed.stdout + ("\n" if completed.stdout and completed.stderr else "") + completed.stderr).strip() + output = (completed.stdout + ( + "\n" if completed.stdout and completed.stderr else "") + completed.stderr).strip() sections.extend((f"status: executed (exit {completed.returncode})", output[:MAX_OUTPUT_CHARS], "")) report = "\n".join(sections) output_path = context.workspace / "sysinternals_report.txt" diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index d5e74672..cdfc090f 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -13,9 +13,9 @@ def _is_access_denied(detail: str, return_code: int) -> bool: """Recognize textual and HRESULT access-denied results from manage-bde.""" normalized = detail.casefold() return ( - return_code == 2147749891 - or "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) + return_code == 2147749891 + or "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) ) @@ -57,7 +57,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"manage-bde exit code {completed.returncode}" if _is_access_denied(detail, completed.returncode): - return CollectorResult(CollectorStatus.SKIPPED, "BitLocker status access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "BitLocker status access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "BitLocker status query failed", errors=(detail,)) output = context.workspace / "bitlocker_status.txt" output.write_text(completed.stdout, encoding="utf-8") diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 8a6f1162..dc039af1 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -79,12 +79,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "BitLocker volume access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "BitLocker volume access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query failed", errors=(detail,)) try: volumes = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned invalid JSON", + errors=(str(error),)) if not isinstance(volumes, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned an unexpected result") report = { diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 70c9e2b7..37d74ba7 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -66,13 +66,16 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Application event-log access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Application event-log access was denied for the current account", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Application event-log query failed", errors=(detail,)) output = context.workspace / "application_events.csv" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/csv") event_count = max(0, len(completed.stdout.splitlines()) - 1) - context.report_progress("application_events_finished", event_count=event_count, bytes_written=artifact.size_bytes) + context.report_progress("application_events_finished", event_count=event_count, + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("Application event-log sample collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index 3638b3a5..2c5fa6ff 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -15,9 +15,9 @@ def _is_access_denied(detail: str) -> bool: """Recognize common access-denied wording emitted by PowerShell event queries.""" normalized = detail.casefold() return ( - "permission denied" in normalized - or "unauthorized" in normalized - or ("access" in normalized and "denied" in normalized) + "permission denied" in normalized + or "unauthorized" in normalized + or ("access" in normalized and "denied" in normalized) ) @@ -28,9 +28,11 @@ class SecurityEventsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Security-event sample and output limit.""" return CollectorMetadata( - id="core.event_log.security_events", name="Security event log", version="4.0.0", specialty=Specialty.EVENT_LOG, + id="core.event_log.security_events", name="Security event log", version="4.0.0", + specialty=Specialty.EVENT_LOG, description="Exports up to 1,000 local Windows Security events as CSV.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("event_logs",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=8 * 1024 * 1024, ) @@ -51,11 +53,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: f"Get-WinEvent -LogName Security -MaxEvents {_MAX_EVENTS} | " "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | ConvertTo-Csv -NoTypeInformation" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=75) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=75) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Security event-log access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Security event-log access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Security event-log query failed", errors=(detail,)) output = context.workspace / "security_events.csv" output.write_text(completed.stdout, encoding="utf-8") diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index 551976fe..ef6d1602 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -10,9 +10,10 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus - -KEYWORDS = ("password", "secret", "code", "login", "api", "key", "token", "auth", "credential", "private", "certificate", "ssh", "pgp", "wallet") -EXTENSIONS = {".txt", ".csv", ".json", ".xml", ".yml", ".yaml", ".ini", ".cfg", ".conf", ".log", ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".db", ".sqlite", ".pem", ".key", ".ppk"} +KEYWORDS = ("password", "secret", "code", "login", "api", "key", "token", "auth", "credential", "private", + "certificate", "ssh", "pgp", "wallet") +EXTENSIONS = {".txt", ".csv", ".json", ".xml", ".yml", ".yaml", ".ini", ".cfg", ".conf", ".log", ".pdf", ".doc", + ".docx", ".xls", ".xlsx", ".zip", ".db", ".sqlite", ".pem", ".key", ".ppk"} MAX_FILE_BYTES = 10 * 1024 * 1024 @@ -33,10 +34,13 @@ class SensitiveFileInventoryCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent sensitive-file inventory artifact contract.""" return CollectorMetadata( - id="core.filesystem.sensitive_file_inventory", name="Sensitive file inventory", version="4.0.0", specialty=Specialty.FILESYSTEM, - description="Finds and copies bounded supported files with sensitive-data keywords in their names.", author="Logicytics", + id="core.filesystem.sensitive_file_inventory", name="Sensitive file inventory", version="4.0.0", + specialty=Specialty.FILESYSTEM, + description="Finds and copies bounded supported files with sensitive-data keywords in their names.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), - sensitive_data_categories=("credentials", "private_keys", "personal_documents"), default_profiles=("deep",), timeout_seconds=300, maximum_output_bytes=256 * 1024 * 1024, + sensitive_data_categories=("credentials", "private_keys", "personal_documents"), default_profiles=("deep",), + timeout_seconds=300, maximum_output_bytes=256 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -44,7 +48,8 @@ def validate(self, context: CollectorContext) -> ValidationResult: if context.is_cancelled: return ValidationResult(False, reasons=("run cancellation was requested",)) try: - if int(context.settings.get("max_directories", 5_000)) < 1 or int(context.settings.get("max_matches", 500)) < 1: + if int(context.settings.get("max_directories", 5_000)) < 1 or int( + context.settings.get("max_matches", 500)) < 1: return ValidationResult(False, reasons=("max_directories and max_matches must be positive",)) except (TypeError, ValueError): return ValidationResult(False, reasons=("max_directories and max_matches must be integers",)) @@ -82,11 +87,16 @@ def collect(self, context: CollectorContext) -> CollectorResult: break destination_root = context.workspace / "sensitive_file_inventory" with ThreadPoolExecutor(max_workers=4) as executor: - copied = [item for item in executor.map(lambda source: _copy(source, destination_root / source.drive.replace(":", "") / source.relative_to(root)), matches) if item is not None] + copied = [item for item in executor.map(lambda source: _copy(source, + destination_root / source.drive.replace(":", + "") / source.relative_to( + root)), matches) if item is not None] if not copied: - return CollectorResult(CollectorStatus.SKIPPED, "no sensitive-named supported files met the bounded inventory policy") + return CollectorResult(CollectorStatus.SKIPPED, + "no sensitive-named supported files met the bounded inventory policy") artifacts = tuple(context.artifacts.register_file(path) for path in copied) - context.report_progress("sensitive_file_inventory_finished", scanned_directories=scanned_directories, copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + context.report_progress("sensitive_file_inventory_finished", scanned_directories=scanned_directories, + copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("sensitive file inventory collected", artifacts) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py index c53cf419..1739e305 100644 --- a/core/filesystem/system_drive_listing.py +++ b/core/filesystem/system_drive_listing.py @@ -49,8 +49,10 @@ class SystemDriveListingCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the filesystem-read, threaded system-drive listing contract.""" return CollectorMetadata( - id="core.filesystem.system_drive_listing", name="System drive listing", version="4.0.0", specialty=Specialty.FILESYSTEM, - description="Exports a configurable, bounded threaded recursive listing of the Windows system drive.", author="Logicytics", + id="core.filesystem.system_drive_listing", name="System drive listing", version="4.0.0", + specialty=Specialty.FILESYSTEM, + description="Exports a configurable, bounded threaded recursive listing of the Windows system drive.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=8 * 1024 * 1024, @@ -73,11 +75,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: maximum_depth = _setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) workers = _setting(context.settings, "workers", _DEFAULT_WORKERS, _HARD_MAX_WORKERS) root = Path(os.environ.get("SystemDrive", "C:") + "\\") - lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}", f"# workers={workers}"] + lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}", + f"# workers={workers}"] pending: dict[Future[tuple[list[str], list[Path]]], tuple[Path, int]] = {} entries = 0 truncated = False - context.report_progress("system_drive_listing_started", max_entries=maximum_entries, max_depth=maximum_depth, workers=workers) + context.report_progress("system_drive_listing_started", max_entries=maximum_entries, max_depth=maximum_depth, + workers=workers) with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="LogicyticsList") as executor: pending[executor.submit(_scan_directory, root)] = (root, 0) while pending and entries < maximum_entries: @@ -104,7 +108,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if context.is_cancelled: for future in pending: future.cancel() - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive listing collection") + return CollectorResult(CollectorStatus.CANCELLED, + "cancelled during system-drive listing collection") if truncated: break if truncated: @@ -112,7 +117,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "system_drive_listing.txt" output.write_text("\n".join(lines) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") - context.report_progress("system_drive_listing_finished", entry_count=entries, truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + context.report_progress("system_drive_listing_finished", entry_count=entries, truncated=str(truncated).lower(), + bytes_written=artifact.size_bytes) summary = "threaded system-drive listing collected" if not truncated else "threaded system-drive listing collected with configured entry limit" return CollectorResult.succeeded(summary, (artifact,)) diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py index 941b0b23..ca935eec 100644 --- a/core/filesystem/system_drive_tree.py +++ b/core/filesystem/system_drive_tree.py @@ -27,8 +27,10 @@ class SystemDriveTreeCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the filesystem-read, bounded system-drive tree artifact contract.""" return CollectorMetadata( - id="core.filesystem.system_drive_tree", name="System drive tree", version="4.0.0", specialty=Specialty.FILESYSTEM, - description="Exports a configurable bounded recursive directory tree for the Windows system drive.", author="Logicytics", + id="core.filesystem.system_drive_tree", name="System drive tree", version="4.0.0", + specialty=Specialty.FILESYSTEM, + description="Exports a configurable bounded recursive directory tree for the Windows system drive.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=120, maximum_output_bytes=8 * 1024 * 1024, @@ -55,7 +57,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: skipped = 0 truncated = False context.report_progress("system_drive_tree_started", max_entries=maximum_entries, max_depth=maximum_depth) - for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, onerror=lambda _error: None): + for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, + onerror=lambda _error: None): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive tree collection") relative = Path(current).relative_to(root) @@ -76,7 +79,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "system_drive_tree.txt" output.write_text("\n".join(lines) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") - context.report_progress("system_drive_tree_finished", entry_count=entries, skipped_directories=skipped, truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + context.report_progress("system_drive_tree_finished", entry_count=entries, skipped_directories=skipped, + truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) summary = "system-drive tree collected" if not truncated else "system-drive tree collected with configured entry limit" return CollectorResult.succeeded(summary, (artifact,)) diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index 47632211..4c5fdbb7 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -24,8 +24,10 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated battery-status JSON artifact contract.""" return CollectorMetadata( id="core.hardware.battery_status", name="Battery status", version="4.0.0", specialty=Specialty.HARDWARE, - description="Exports local battery name, status, charge, capacity, and estimated runtime metadata.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + description="Exports local battery name, status, charge, capacity, and estimated runtime metadata.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=128 * 1024, ) @@ -47,16 +49,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Select-Object Name, BatteryStatus, EstimatedChargeRemaining, EstimatedRunTime, DesignCapacity, FullChargeCapacity, Status | " "ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=40) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "battery-status access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "battery-status access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "battery-status query failed", errors=(detail,)) try: batteries = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "battery-status query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "battery-status query returned invalid JSON", + errors=(str(error),)) if not isinstance(batteries, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "battery-status query returned an unexpected result") output = context.workspace / "battery_status.json" diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index a7bd32b2..4be2f2e3 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -92,7 +92,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "windows_features.json" output.write_text(json.dumps(features, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("windows_features_finished", feature_count=len(features), bytes_written=artifact.size_bytes) + context.report_progress("windows_features_finished", feature_count=len(features), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("Windows optional features collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/media/media_backup.py b/core/media/media_backup.py index 1b3779be..f0465a4c 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -9,7 +9,6 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus - SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} MAX_FILE_BYTES = 50 * 1024 * 1024 MAX_TOTAL_BYTES = 512 * 1024 * 1024 @@ -25,8 +24,10 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.media.media_backup", name="Pictures and Videos backup", version="4.0.0", specialty=Specialty.MEDIA, description="Copies bounded JPG, JPEG, PNG, and MP4 files from current-user Pictures and Videos folders.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), - sensitive_data_categories=("personal_media",), default_profiles=("deep",), timeout_seconds=300, maximum_output_bytes=512 * 1024 * 1024, + author="Logicytics", supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + sensitive_data_categories=("personal_media",), default_profiles=("deep",), timeout_seconds=300, + maximum_output_bytes=512 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -71,7 +72,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES: skipped_files += 1 continue - timestamp = datetime.fromtimestamp(candidate.stat().st_mtime, timezone.utc).strftime("%Y%m%dT%H%M%SZ") + timestamp = datetime.fromtimestamp(candidate.stat().st_mtime, timezone.utc).strftime( + "%Y%m%dT%H%M%SZ") destination = destination_root / category / f"{candidate.stem}_{timestamp}{candidate.suffix.casefold()}" suffix = 1 while destination.exists(): @@ -89,7 +91,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: reason = "the current user's Pictures and Videos folders are inaccessible" return CollectorResult(CollectorStatus.SKIPPED, reason) artifacts = tuple(context.artifacts.register_file(path) for path in copied) - context.report_progress("media_backup_finished", copied_files=len(artifacts), skipped_files=skipped_files, bytes_written=sum(item.size_bytes for item in artifacts)) + context.report_progress("media_backup_finished", copied_files=len(artifacts), skipped_files=skipped_files, + bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("current-user media backup collected", artifacts) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/active_connections.py b/core/network/active_connections.py index 399d4a77..95a0a070 100644 --- a/core/network/active_connections.py +++ b/core/network/active_connections.py @@ -59,13 +59,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "netstat access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, "netstat access was denied for the current account", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "active-connection query failed", errors=(detail,)) output = context.workspace / "active_connections.txt" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") connection_count = sum(1 for line in completed.stdout.splitlines() if line.lstrip().startswith(("TCP", "UDP"))) - context.report_progress("active_connections_finished", connection_count=connection_count, bytes_written=artifact.size_bytes) + context.report_progress("active_connections_finished", connection_count=connection_count, + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("active network connections collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py index 9f503153..b18e9fe2 100644 --- a/core/network/adapter_statistics.py +++ b/core/network/adapter_statistics.py @@ -66,12 +66,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "adapter-statistics access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "adapter-statistics access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query failed", errors=(detail,)) try: statistics = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned invalid JSON", + errors=(str(error),)) if not isinstance(statistics, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned an unexpected result") output = context.workspace / "adapter_statistics.json" diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index 6e672ce1..81201b6d 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -33,9 +33,12 @@ class BandwidthSampleCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated bounded bandwidth-sampling artifact contract.""" return CollectorMetadata( - id="core.network.bandwidth_sample", name="Network bandwidth sample", version="4.0.0", specialty=Specialty.NETWORK, - description="Calculates local per-interface average and peak bandwidth from adapter counter samples.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_identifiers",), + id="core.network.bandwidth_sample", name="Network bandwidth sample", version="4.0.0", + specialty=Specialty.NETWORK, + description="Calculates local per-interface average and peak bandwidth from adapter counter samples.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_identifiers",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=512 * 1024, ) @@ -59,20 +62,26 @@ def collect(self, context: CollectorContext) -> CollectorResult: for index in range(samples): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during bandwidth sampling") - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=20) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=20) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "bandwidth-sample access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "bandwidth-sample access was denied for the current account", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query failed", errors=(detail,)) try: raw = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned invalid JSON", + errors=(str(error),)) records = raw if isinstance(raw, list) else [raw] if not all(isinstance(record, dict) for record in records): return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned an unexpected result") - observations.append({str(record.get("Name", "unavailable")): {"received": int(record.get("ReceivedBytes", 0)), "sent": int(record.get("SentBytes", 0))} for record in records}) + observations.append({str(record.get("Name", "unavailable")): { + "received": int(record.get("ReceivedBytes", 0)), "sent": int(record.get("SentBytes", 0))} for record in + records}) if index + 1 < samples: time.sleep(interval) rates: dict[str, dict[str, float]] = {} @@ -82,7 +91,10 @@ def collect(self, context: CollectorContext) -> CollectorResult: continue receive = max(0, counters["received"] - previous[name]["received"]) / interval sent = max(0, counters["sent"] - previous[name]["sent"]) / interval - values = rates.setdefault(name, {"receive_average_bytes_per_second": 0.0, "receive_peak_bytes_per_second": 0.0, "send_average_bytes_per_second": 0.0, "send_peak_bytes_per_second": 0.0, "sample_intervals": 0.0}) + values = rates.setdefault(name, {"receive_average_bytes_per_second": 0.0, + "receive_peak_bytes_per_second": 0.0, + "send_average_bytes_per_second": 0.0, + "send_peak_bytes_per_second": 0.0, "sample_intervals": 0.0}) values["sample_intervals"] += 1 values["receive_average_bytes_per_second"] += receive values["send_average_bytes_per_second"] += sent @@ -92,9 +104,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: values["receive_average_bytes_per_second"] /= values["sample_intervals"] values["send_average_bytes_per_second"] /= values["sample_intervals"] output = context.workspace / "bandwidth_sample.json" - output.write_text(json.dumps({"sample_count": samples, "interval_seconds": interval, "interfaces": rates}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + output.write_text( + json.dumps({"sample_count": samples, "interval_seconds": interval, "interfaces": rates}, indent=2, + sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bandwidth_sample_finished", interface_count=len(rates), bytes_written=artifact.size_bytes) + context.report_progress("bandwidth_sample_finished", interface_count=len(rates), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("network bandwidth sample collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py index 19c7a5c5..1d1d9c77 100644 --- a/core/network/connection_processes.py +++ b/core/network/connection_processes.py @@ -31,7 +31,8 @@ def _parse_connections(netstat_output: str) -> list[dict[str, str]]: state = "" else: continue - connections.append({"protocol": protocol, "local_endpoint": local, "remote_endpoint": remote, "state": state, "pid": pid}) + connections.append( + {"protocol": protocol, "local_endpoint": local, "remote_endpoint": remote, "state": state, "pid": pid}) return connections @@ -80,23 +81,27 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection-process collection") context.report_progress("connection_processes_started") netstat = subprocess.run(["netstat", "-ano"], capture_output=True, check=False, text=True, timeout=25) - tasklist = subprocess.run(["tasklist", "/fo", "csv", "/nh"], capture_output=True, check=False, text=True, timeout=25) + tasklist = subprocess.run(["tasklist", "/fo", "csv", "/nh"], capture_output=True, check=False, text=True, + timeout=25) failures = [result for result in (netstat, tasklist) if result.returncode != 0] if failures: detail = "\n".join(result.stderr.strip() or f"command exit code {result.returncode}" for result in failures) if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "connection-process access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "connection-process access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "connection-process query failed", errors=(detail,)) processes = _parse_processes(tasklist.stdout) rows = _parse_connections(netstat.stdout) output = context.workspace / "connection_processes.csv" with output.open("w", encoding="utf-8", newline="") as stream: - writer = csv.DictWriter(stream, fieldnames=("protocol", "local_endpoint", "remote_endpoint", "state", "pid", "process_name")) + writer = csv.DictWriter(stream, fieldnames=("protocol", "local_endpoint", "remote_endpoint", "state", "pid", + "process_name")) writer.writeheader() for row in rows: writer.writerow({**row, "process_name": processes.get(row["pid"], "unavailable")}) artifact = context.artifacts.register_file(output, media_type="text/csv") - context.report_progress("connection_processes_finished", connection_count=len(rows), bytes_written=artifact.size_bytes) + context.report_progress("connection_processes_finished", connection_count=len(rows), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("connection-process associations collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py index bdfecf3d..fed07ba3 100644 --- a/core/network/dns_cache.py +++ b/core/network/dns_cache.py @@ -23,8 +23,10 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated sensitive DNS-cache artifact contract.""" return CollectorMetadata( id="core.network.dns_cache", name="DNS resolver cache", version="4.0.0", specialty=Specialty.NETWORK, - description="Exports local DNS resolver cache records through read-only ipconfig output.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("dns_history",), + description="Exports local DNS resolver cache records through read-only ipconfig output.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("dns_history",), default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=4 * 1024 * 1024, ) @@ -45,7 +47,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "DNS-cache access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, "DNS-cache access was denied for the current account", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "DNS-cache query failed", errors=(detail,)) output = context.workspace / "dns_cache.txt" output.write_text(completed.stdout, encoding="utf-8") diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index 85b266b0..7324c5a2 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -24,8 +24,10 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated firewall-profile JSON artifact contract.""" return CollectorMetadata( id="core.network.firewall_profiles", name="Firewall profiles", version="4.0.0", specialty=Specialty.NETWORK, - description="Exports local Domain, Private, and Public Windows firewall profile settings.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + description="Exports local Domain, Private, and Public Windows firewall profile settings.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=256 * 1024, ) @@ -47,16 +49,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, NotifyOnListen, AllowInboundRules, AllowLocalFirewallRules, AllowLocalIPsecRules, LogFileName | " "ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=40) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "firewall-profile access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "firewall-profile access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "firewall-profile query failed", errors=(detail,)) try: profiles = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned invalid JSON", + errors=(str(error),)) if not isinstance(profiles, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned an unexpected result") output = context.workspace / "firewall_profiles.json" diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py index deabf94f..24f0f7ab 100644 --- a/core/network/network_adapters.py +++ b/core/network/network_adapters.py @@ -59,7 +59,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}" if "permission denied" in detail.casefold() or ( - "access" in detail.casefold() and "denied" in detail.casefold() + "access" in detail.casefold() and "denied" in detail.casefold() ): return CollectorResult( CollectorStatus.SKIPPED, diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index a24f5ea9..6602126a 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -90,19 +90,22 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "network-interface access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "network-interface access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "network-interface query failed", errors=(detail,)) try: interfaces = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "network-interface query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "network-interface query returned invalid JSON", + errors=(str(error),)) records = interfaces if isinstance(interfaces, list) else [interfaces] if not all(isinstance(record, dict) for record in records): return CollectorResult(CollectorStatus.FAILED, "network-interface query returned an unexpected result") output = context.workspace / "network_interfaces.json" output.write_text(json.dumps(_enrich_ipv4_networks(records), indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("network_interfaces_finished", interface_count=len(records), bytes_written=artifact.size_bytes) + context.report_progress("network_interfaces_finished", interface_count=len(records), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("network-interface inventory collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 14ade079..6988b6a8 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -23,8 +23,10 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated network graph artifact contract.""" return CollectorMetadata( id="core.packet.connection_graph", name="Connection graph", version="4.0.0", specialty=Specialty.PACKET, - description="Exports a DOT source/destination graph with TCP or UDP protocol edge labels.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_metadata",), + description="Exports a DOT source/destination graph with TCP or UDP protocol edge labels.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_metadata",), default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=2 * 1024 * 1024, ) @@ -45,7 +47,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "connection graph access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "connection graph access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "connection graph query failed", errors=(detail,)) edges: set[tuple[str, str, str]] = set() for line in completed.stdout.splitlines(): @@ -56,10 +59,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if destination in {"*:*", "*"}: continue edges.add((source, destination, protocol.upper())) + def quote(value: str) -> str: return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' + lines = ["digraph connection_graph {", " rankdir=LR;"] - lines.extend(f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" for source, destination, protocol in sorted(edges)) + lines.extend( + f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" for source, destination, protocol in + sorted(edges)) lines.append("}") output = context.workspace / "connection_graph.dot" output.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index e8e430c2..1702c7e0 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -3,11 +3,12 @@ from __future__ import annotations import csv -import select import socket import struct import time +import select + from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus @@ -25,8 +26,10 @@ def _packet_row(payload: bytes) -> dict[str, str] | None: destination = socket.inet_ntoa(payload[16:20]) source_port = destination_port = "" if protocol in {6, 17} and len(payload) >= header_length + 4: - source_port, destination_port = (str(value) for value in struct.unpack("!HH", payload[header_length:header_length + 4])) - return {"source_ip": source, "destination_ip": destination, "protocol": names.get(protocol, str(protocol)), "source_port": source_port, "destination_port": destination_port, "packet_bytes": str(len(payload))} + source_port, destination_port = (str(value) for value in + struct.unpack("!HH", payload[header_length:header_length + 4])) + return {"source_ip": source, "destination_ip": destination, "protocol": names.get(protocol, str(protocol)), + "source_port": source_port, "destination_port": destination_port, "packet_bytes": str(len(payload))} class PacketCaptureCollector(CoreCollector): @@ -38,8 +41,10 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.packet.packet_capture", name="IPv4 packet capture", version="4.0.0", specialty=Specialty.PACKET, description="Captures bounded IPv4 packet metadata without saving packet payloads.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.NETWORK, Capability.PACKET_CAPTURE, Capability.ELEVATED_PRIVILEGES), - sensitive_data_categories=("network_metadata",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=2 * 1024 * 1024, + supported_platforms=("win32",), + capabilities=(Capability.NETWORK, Capability.PACKET_CAPTURE, Capability.ELEVATED_PRIVILEGES), + sensitive_data_categories=("network_metadata",), default_profiles=("deep",), timeout_seconds=90, + maximum_output_bytes=2 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -81,10 +86,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: if row is not None: observations.append(row) except PermissionError as error: - return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture requires an elevated account", errors=(str(error),)) + return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture requires an elevated account", + errors=(str(error),)) except OSError as error: if error.winerror in {5, 10013}: - return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture was denied for the current account", errors=(str(error),)) + return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture was denied for the current account", + errors=(str(error),)) return CollectorResult(CollectorStatus.FAILED, "raw packet capture failed", errors=(str(error),)) finally: if capture is not None: @@ -95,11 +102,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: capture.close() output = context.workspace / "packet_capture.csv" with output.open("w", newline="", encoding="utf-8") as stream: - writer = csv.DictWriter(stream, fieldnames=("source_ip", "destination_ip", "protocol", "source_port", "destination_port", "packet_bytes")) + writer = csv.DictWriter(stream, fieldnames=("source_ip", "destination_ip", "protocol", "source_port", + "destination_port", "packet_bytes")) writer.writeheader() writer.writerows(observations) artifact = context.artifacts.register_file(output, media_type="text/csv") - context.report_progress("packet_capture_finished", observation_count=len(observations), bytes_written=artifact.size_bytes) + context.report_progress("packet_capture_finished", observation_count=len(observations), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("packet metadata captured", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py index 0390b5c2..0a57ae3c 100644 --- a/core/process/detailed_processes.py +++ b/core/process/detailed_processes.py @@ -59,13 +59,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"tasklist exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "tasklist access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, "tasklist access was denied for the current account", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "detailed tasklist query failed", errors=(detail,)) output = context.workspace / "detailed_processes.csv" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/csv") process_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) - context.report_progress("detailed_processes_finished", process_count=process_count, bytes_written=artifact.size_bytes) + context.report_progress("detailed_processes_finished", process_count=process_count, + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("detailed process list collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/process/memory_map.py b/core/process/memory_map.py index ae7def9a..2574cdd6 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -13,16 +13,23 @@ class _MemoryBasicInformation(ctypes.Structure): - _fields_ = [("BaseAddress", wintypes.LPVOID), ("AllocationBase", wintypes.LPVOID), ("AllocationProtect", wintypes.DWORD), ("PartitionId", wintypes.WORD), ("RegionSize", ctypes.c_size_t), ("State", wintypes.DWORD), ("Protect", wintypes.DWORD), ("Type", wintypes.DWORD)] + _fields_ = [("BaseAddress", wintypes.LPVOID), ("AllocationBase", wintypes.LPVOID), + ("AllocationProtect", wintypes.DWORD), ("PartitionId", wintypes.WORD), ("RegionSize", ctypes.c_size_t), + ("State", wintypes.DWORD), ("Protect", wintypes.DWORD), ("Type", wintypes.DWORD)] class _ProcessMemoryCounters(ctypes.Structure): - _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), ("PeakWorkingSetSize", ctypes.c_size_t), ("WorkingSetSize", ctypes.c_size_t), ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), ("QuotaPagedPoolUsage", ctypes.c_size_t), ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), ("QuotaNonPagedPoolUsage", ctypes.c_size_t), ("PagefileUsage", ctypes.c_size_t), ("PeakPagefileUsage", ctypes.c_size_t)] + _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t)] def _permissions(protection: int) -> str: """Return a readable Windows page-protection summary.""" - values = {0x02: "read", 0x04: "read_write", 0x08: "write_copy", 0x20: "execute_read", 0x40: "execute_read_write", 0x80: "execute_write_copy"} + values = {0x02: "read", 0x04: "read_write", 0x08: "write_copy", 0x20: "execute_read", 0x40: "execute_read_write", + 0x80: "execute_write_copy"} return values.get(protection & 0xFF, "unreadable") @@ -34,8 +41,10 @@ def metadata(cls) -> CollectorMetadata: """Declare the bounded memory-region JSON artifact contract.""" return CollectorMetadata( id="core.process.memory_map", name="Process memory map", version="4.0.0", specialty=Specialty.PROCESS, - description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.", author="Logicytics", - supported_platforms=("win32",), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=64 * 1024 * 1024, + description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.", + author="Logicytics", + supported_platforms=("win32",), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), + timeout_seconds=90, maximum_output_bytes=64 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -64,7 +73,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: output_directory.relative_to(context.workspace.resolve()) except ValueError: - return CollectorResult(CollectorStatus.FAILED, "memory-map dump_directory must stay inside the collector workspace") + return CollectorResult(CollectorStatus.FAILED, + "memory-map dump_directory must stay inside the collector workspace") kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) psapi = ctypes.WinDLL("psapi", use_last_error=True) process = kernel32.GetCurrentProcess() @@ -85,25 +95,31 @@ def collect(self, context: CollectorContext) -> CollectorResult: if readable: mapped = ctypes.create_unicode_buffer(32_768) mapped_length = psapi.GetMappedFileNameW(process, ctypes.c_void_p(base), mapped, len(mapped)) - regions.append({"index": len(regions), "address": f"0x{base:016X}", "size_bytes": memory.RegionSize, "rss_bytes": counters.WorkingSetSize, "permissions": _permissions(memory.Protect), "mapped_path": mapped.value if mapped_length else None, "state": memory.State, "type": memory.Type}) + regions.append({"index": len(regions), "address": f"0x{base:016X}", "size_bytes": memory.RegionSize, + "rss_bytes": counters.WorkingSetSize, "permissions": _permissions(memory.Protect), + "mapped_path": mapped.value if mapped_length else None, "state": memory.State, + "type": memory.Type}) next_address = base + memory.RegionSize if next_address <= address: break address = next_address truncated = False while True: - serialized = json.dumps({"region_count": len(regions), "truncated": truncated, "regions": regions}, indent=2) + "\n" + serialized = json.dumps({"region_count": len(regions), "truncated": truncated, "regions": regions}, + indent=2) + "\n" if len(serialized.encode("utf-8")) <= output_limit or not regions: break regions.pop() truncated = True if shutil.disk_usage(context.workspace).free < len(serialized.encode("utf-8")) + safety_margin: - return CollectorResult(CollectorStatus.SKIPPED, "insufficient free disk space after configured memory-map safety margin") + return CollectorResult(CollectorStatus.SKIPPED, + "insufficient free disk space after configured memory-map safety margin") output_directory.mkdir(parents=True, exist_ok=True) output = output_directory / "memory_map.json" output.write_text(serialized, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("memory_map_finished", region_count=len(regions), truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + context.report_progress("memory_map_finished", region_count=len(regions), truncated=str(truncated).lower(), + bytes_written=artifact.size_bytes) summary = "process memory map collected" if not truncated else "process memory map collected with configured output truncation" return CollectorResult.succeeded(summary, (artifact,)) diff --git a/core/process/process_memory.py b/core/process/process_memory.py index 26d6cfc9..f8c0bacd 100644 --- a/core/process/process_memory.py +++ b/core/process/process_memory.py @@ -48,16 +48,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Select-Object Id, ProcessName, WorkingSet64, PrivateMemorySize64, VirtualMemorySize64, HandleCount, CPU, StartTime | " "ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=55) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=55) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "process-memory access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "process-memory access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "process-memory query failed", errors=(detail,)) try: processes = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "process-memory query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "process-memory query returned invalid JSON", + errors=(str(error),)) if not isinstance(processes, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "process-memory query returned an unexpected result") output = context.workspace / "process_memory.json" diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index efffc117..87c80997 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -13,9 +13,9 @@ def _is_access_denied(detail: str) -> bool: """Recognize Windows permission-denied or elevation-required wording.""" normalized = detail.casefold() return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "requires elevation" in normalized + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "requires elevation" in normalized ) @@ -57,7 +57,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: message = detail or f"reg.exe exit code {completed.returncode}" if _is_access_denied(message): - return CollectorResult(CollectorStatus.SKIPPED, "HKLM registry export was denied for the current account", errors=(message,)) + return CollectorResult(CollectorStatus.SKIPPED, + "HKLM registry export was denied for the current account", errors=(message,)) return CollectorResult(CollectorStatus.FAILED, "HKLM registry export failed", errors=(message,)) if not output.is_file() or output.stat().st_size == 0: return CollectorResult(CollectorStatus.FAILED, "HKLM registry export produced no backup artifact") diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py index ad4cf44f..f790409a 100644 --- a/core/registry/installed_applications.py +++ b/core/registry/installed_applications.py @@ -52,7 +52,11 @@ def _installed_applications() -> list[dict[str, str | None]]: if identity in seen: continue seen.add(identity) - applications.append({"display_name": display_name, "display_version": version or None, "publisher": _registry_value(key, "Publisher"), "install_date": _registry_value(key, "InstallDate"), "install_location": _registry_value(key, "InstallLocation"), "uninstall_key": key_name}) + applications.append({"display_name": display_name, "display_version": version or None, + "publisher": _registry_value(key, "Publisher"), + "install_date": _registry_value(key, "InstallDate"), + "install_location": _registry_value(key, "InstallLocation"), + "uninstall_key": key_name}) except OSError: continue return sorted(applications, key=lambda item: (item["display_name"] or "").casefold()) @@ -65,9 +69,12 @@ class InstalledApplicationsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the registry-read, bounded installed-application artifact contract.""" return CollectorMetadata( - id="core.registry.installed_applications", name="Installed applications", version="4.0.0", specialty=Specialty.REGISTRY, - description="Exports installed application names, versions, publishers, and install metadata from uninstall keys.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), + id="core.registry.installed_applications", name="Installed applications", version="4.0.0", + specialty=Specialty.REGISTRY, + description="Exports installed application names, versions, publishers, and install metadata from uninstall keys.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, ) @@ -90,9 +97,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("installed_applications_started") applications = _installed_applications() output = context.workspace / "installed_applications.json" - output.write_text(json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "applications": applications}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + output.write_text( + json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "applications": applications}, indent=2, + sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("installed_applications_finished", application_count=len(applications), bytes_written=artifact.size_bytes) + context.report_progress("installed_applications_finished", application_count=len(applications), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("installed applications collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py index 04ead920..e0d94bf2 100644 --- a/core/registry/startup_applications.py +++ b/core/registry/startup_applications.py @@ -44,9 +44,11 @@ class StartupApplicationsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the registry-read startup-application artifact contract.""" return CollectorMetadata( - id="core.registry.startup_applications", name="Startup applications", version="4.0.0", specialty=Specialty.REGISTRY, + id="core.registry.startup_applications", name="Startup applications", version="4.0.0", + specialty=Specialty.REGISTRY, description="Exports standard user and machine Run/RunOnce startup registry entries.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), + supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=512 * 1024, ) @@ -63,9 +65,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("startup_applications_started") entries = _startup_entries() output = context.workspace / "startup_applications.json" - output.write_text(json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "entries": entries}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + output.write_text( + json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "entries": entries}, indent=2, + sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("startup_applications_finished", entry_count=len(entries), bytes_written=artifact.size_bytes) + context.report_progress("startup_applications_finished", entry_count=len(entries), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("startup applications collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index a3889d99..e024bc6a 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -8,7 +8,6 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus - MAX_FILE_BYTES = 10 * 1024 * 1024 MAX_ARCHIVE_SOURCE_BYTES = 128 * 1024 * 1024 @@ -42,7 +41,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: directory_exists = ssh_directory.is_dir() except OSError as error: - return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", errors=(str(error),)) + return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", + errors=(str(error),)) if not directory_exists: return CollectorResult(CollectorStatus.SKIPPED, "the current user has no .ssh directory") context.report_progress("ssh_backup_started") @@ -54,7 +54,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: candidates = sorted(ssh_directory.rglob("*")) except OSError as error: - return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", errors=(str(error),)) + return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", + errors=(str(error),)) for candidate in candidates: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during SSH backup") diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index 47ef6212..a7f6a4b0 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -34,10 +34,10 @@ def _logical_drives() -> list[dict[str, int | str]]: total = ctypes.c_ulonglong() free = ctypes.c_ulonglong() if not ctypes.windll.kernel32.GetDiskFreeSpaceExW( - root, - ctypes.byref(available), - ctypes.byref(total), - ctypes.byref(free), + root, + ctypes.byref(available), + ctypes.byref(total), + ctypes.byref(free), ): continue drive_type_code = ctypes.windll.kernel32.GetDriveTypeW(root) diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index 9d7fcda0..37ad5ca8 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -53,13 +53,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"mountvol exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "mounted-volume access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "mounted-volume access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "mounted-volume query failed", errors=(detail,)) output = context.workspace / "mounted_volumes.txt" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") volume_count = sum(1 for line in completed.stdout.splitlines() if line.strip().startswith("\\\\?\\Volume{")) - context.report_progress("mounted_volumes_finished", volume_count=volume_count, bytes_written=artifact.size_bytes) + context.report_progress("mounted_volumes_finished", volume_count=volume_count, + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("mounted-volume mappings collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index 5d01bcdd..790455fc 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -65,12 +65,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "physical-disk CIM access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "physical-disk CIM access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query failed", errors=(detail,)) try: physical_disks = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned invalid JSON", + errors=(str(error),)) if not isinstance(physical_disks, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned an unexpected result") output = context.workspace / "physical_disks.json" diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index 98b7184d..37e8a99f 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -10,7 +10,8 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -_DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", 6: "ram_disk"} +_DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", + 6: "ram_disk"} def _volume_details() -> list[dict[str, int | str]]: @@ -35,7 +36,8 @@ def _volume_details() -> list[dict[str, int | str]]: maximum_component_length = wintypes.DWORD() flags = wintypes.DWORD() information_available = kernel32.GetVolumeInformationW( - root, label, len(label), ctypes.byref(serial), ctypes.byref(maximum_component_length), ctypes.byref(flags), filesystem, len(filesystem) + root, label, len(label), ctypes.byref(serial), ctypes.byref(maximum_component_length), ctypes.byref(flags), + filesystem, len(filesystem) ) volumes.append({ "root": root, @@ -58,7 +60,8 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.storage.volume_details", name="Volume details", version="4.0.0", specialty=Specialty.STORAGE, description="Exports mounted drive type, filesystem, label, and capacity metadata.", author="Logicytics", - supported_platforms=("win32",), default_profiles=("deep",), timeout_seconds=15, maximum_output_bytes=128 * 1024, + supported_platforms=("win32",), default_profiles=("deep",), timeout_seconds=15, + maximum_output_bytes=128 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -79,9 +82,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: volumes = _volume_details() except OSError as error: - return CollectorResult(CollectorStatus.FAILED, "could not read detailed volume metadata", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "could not read detailed volume metadata", + errors=(str(error),)) output = context.workspace / "volume_details.json" - output.write_text(json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "volumes": volumes}, indent=2, sort_keys=True) + "\n", encoding="utf-8") + output.write_text( + json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "volumes": volumes}, indent=2, + sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") context.report_progress("volume_details_finished", volume_count=len(volumes), bytes_written=artifact.size_bytes) return CollectorResult.succeeded("detailed volume metadata collected", (artifact,)) diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index b0eb29e2..4542ab55 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -64,12 +64,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "environment-posture access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "environment-posture access was denied for the current account", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "environment-posture query failed", errors=(detail,)) try: posture = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned invalid JSON", + errors=(str(error),)) if not isinstance(posture, dict): return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned an unexpected result") output = context.workspace / "environment_posture.json" diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index 69b5ec28..7e65a201 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -75,7 +75,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/csv") driver_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) - context.report_progress("installed_drivers_finished", driver_count=driver_count, bytes_written=artifact.size_bytes) + context.report_progress("installed_drivers_finished", driver_count=driver_count, + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("installed-driver inventory collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index 46a3e3a5..a7861f21 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -23,9 +23,12 @@ class InstalledUpdatesCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated installed-updates JSON artifact contract.""" return CollectorMetadata( - id="core.system.installed_updates", name="Installed Windows updates", version="4.0.0", specialty=Specialty.SYSTEM, - description="Exports local installed hotfix identifiers, descriptions, and install dates.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + id="core.system.installed_updates", name="Installed Windows updates", version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports local installed hotfix identifiers, descriptions, and install dates.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=512 * 1024, ) @@ -43,16 +46,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before installed-update collection") context.report_progress("installed_updates_started") command = "Get-HotFix | Select-Object HotFixID, Description, InstalledBy, InstalledOn | ConvertTo-Json -Depth 3" - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=40) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "installed-update access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "installed-update access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "installed-update query failed", errors=(detail,)) try: updates = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "installed-update query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "installed-update query returned invalid JSON", + errors=(str(error),)) if not isinstance(updates, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "installed-update query returned an unexpected result") output = context.workspace / "installed_updates.json" diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py index 6df56fc6..e32258b6 100644 --- a/core/system/session_snapshot.py +++ b/core/system/session_snapshot.py @@ -74,12 +74,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "session-snapshot access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "session-snapshot access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "session-snapshot query failed", errors=(detail,)) try: snapshot = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned invalid JSON", + errors=(str(error),)) if not isinstance(snapshot, dict): return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned an unexpected result") output = context.workspace / "session_snapshot.json" diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index deaa2aa8..2ba48065 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -51,16 +51,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Machine = $env:PROCESSOR_IDENTIFIER; PageSizeBytes = [Environment]::SystemPageSize; ProcessorCount = $cpu.Count; " "Processors = $cpu; LastBootUpTime = $os.LastBootUpTime; CollectedAt = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 5" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=40) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "system-diagnostics access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "system-diagnostics access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query failed", errors=(detail,)) try: diagnostics = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned invalid JSON", + errors=(str(error),)) if not isinstance(diagnostics, dict): return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned an unexpected result") output = context.workspace / "system_diagnostics.json" diff --git a/core/system/windows_services.py b/core/system/windows_services.py index adc0d365..57891969 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -24,8 +24,10 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Windows-services JSON artifact contract.""" return CollectorMetadata( id="core.system.windows_services", name="Windows services", version="4.0.0", specialty=Specialty.SYSTEM, - description="Exports local Windows service names, states, start modes, accounts, and executable paths.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), + description="Exports local Windows service names, states, start modes, accounts, and executable paths.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, ) @@ -46,16 +48,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Get-CimInstance -ClassName Win32_Service | " "Select-Object Name, DisplayName, State, StartMode, StartName, PathName, ProcessId | ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=55) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=55) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Windows-service access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Windows-service access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Windows-service query failed", errors=(detail,)) try: services = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned invalid JSON", + errors=(str(error),)) if not isinstance(services, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned an unexpected result") output = context.workspace / "windows_services.json" diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index ca2c7f5d..580b8682 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -9,7 +9,6 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus - MAX_FILE_BYTES = 100 * 1024 * 1024 @@ -20,10 +19,13 @@ class WindowsSystemDataBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent Windows system-data backup contract.""" return CollectorMetadata( - id="core.system.windows_system_data_backup", name="Windows system-data backup", version="4.0.0", specialty=Specialty.SYSTEM, - description="Copies bounded Group Policy, event-log, and Windows security-support evidence.", author="Logicytics", + id="core.system.windows_system_data_backup", name="Windows system-data backup", version="4.0.0", + specialty=Specialty.SYSTEM, + description="Copies bounded Group Policy, event-log, and Windows security-support evidence.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), - sensitive_data_categories=("security_logs", "group_policy", "system_configuration"), default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=256 * 1024 * 1024, + sensitive_data_categories=("security_logs", "group_policy", "system_configuration"), + default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=256 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -45,7 +47,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: ("event_logs", windows / "System32" / "winevt" / "Logs" / "Application.evtx"), ("event_logs", windows / "System32" / "winevt" / "Logs" / "Security.evtx"), ] - candidates.extend(("security_support", path) for path in (program_data / "Microsoft" / "Windows Defender" / "Support").glob("*.log")) + candidates.extend(("security_support", path) for path in + (program_data / "Microsoft" / "Windows Defender" / "Support").glob("*.log")) copied: list[Path] = [] context.report_progress("windows_system_data_backup_started") for label, source in candidates: @@ -61,9 +64,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: continue copied.append(destination) if not copied: - return CollectorResult(CollectorStatus.SKIPPED, "no configured Windows system-data files met the bounded backup policy") + return CollectorResult(CollectorStatus.SKIPPED, + "no configured Windows system-data files met the bounded backup policy") artifacts = tuple(context.artifacts.register_file(path) for path in copied) - context.report_progress("windows_system_data_backup_finished", copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + context.report_progress("windows_system_data_backup_finished", copied_files=len(artifacts), + bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("Windows system-data backup collected", artifacts) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/usb/usb_storage_inventory.py b/core/usb/usb_storage_inventory.py index c7279b01..ddff1412 100644 --- a/core/usb/usb_storage_inventory.py +++ b/core/usb/usb_storage_inventory.py @@ -119,7 +119,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "usb_storage_inventory.json" output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("usb_storage_inventory_finished", device_count=len(devices), bytes_written=artifact.size_bytes) + context.report_progress("usb_storage_inventory_finished", device_count=len(devices), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("USB storage inventory collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py index c8a9e940..20ed9612 100644 --- a/core/wireless/wifi_interfaces.py +++ b/core/wireless/wifi_interfaces.py @@ -14,9 +14,9 @@ def _is_access_denied(detail: str) -> bool: """Recognize common permission-denied wording from netsh output.""" normalized = detail.casefold() return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "requires elevation" in normalized + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "requires elevation" in normalized ) @@ -61,7 +61,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: message = detail or completed.stdout.strip() or f"netsh exit code {completed.returncode}" if _is_access_denied(message): - return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi interface access was denied for the current account", errors=(message,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Wi-Fi interface access was denied for the current account", errors=(message,)) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi interface query failed", errors=(message,)) interfaces: list[dict[str, str]] = [] diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index a706d6b1..0513826f 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -14,9 +14,9 @@ def _is_access_denied(detail: str) -> bool: """Recognize common Windows permission-denied or elevation-required wording.""" normalized = detail.casefold() return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "requires elevation" in normalized + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "requires elevation" in normalized ) @@ -59,13 +59,16 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: message = detail or f"netsh exit code {completed.returncode}" if _is_access_denied(message): - return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi profile-key access was denied for the current account", errors=(message,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Wi-Fi profile-key access was denied for the current account", errors=(message,)) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile-key export failed", errors=(message,)) profiles = sorted(path for path in export_directory.glob("*.xml") if path.is_file()) if not profiles: return CollectorResult(CollectorStatus.SKIPPED, "no saved Wi-Fi profiles with key material were exported") - artifacts = tuple(context.artifacts.register_file(Path(profile), media_type="application/xml") for profile in profiles) - context.report_progress("wifi_profile_keys_finished", profile_count=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) + artifacts = tuple( + context.artifacts.register_file(Path(profile), media_type="application/xml") for profile in profiles) + context.report_progress("wifi_profile_keys_finished", profile_count=len(artifacts), + bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("saved Wi-Fi profile keys collected", artifacts) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py index 2b360f30..d9206e1d 100644 --- a/core/wireless/wifi_profiles.py +++ b/core/wireless/wifi_profiles.py @@ -59,13 +59,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"netsh exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi profile access was denied for the current account", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, + "Wi-Fi profile access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile query failed", errors=(detail,)) output = context.workspace / "wifi_profiles.txt" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") profile_count = sum(1 for line in completed.stdout.splitlines() if " : " in line) - context.report_progress("wifi_profiles_finished", profile_count=profile_count, bytes_written=artifact.size_bytes) + context.report_progress("wifi_profiles_finished", profile_count=profile_count, + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("saved Wi-Fi profiles collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index b65062f5..f32f9ae9 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -32,11 +32,11 @@ class WorkspaceArtifactWriter(ArtifactWriter): """Copies approved files from one collector workspace to the run artifact tree.""" def __init__( - self, - collector_id: str, - workspace: Path, - artifact_root: Path, - maximum_output_bytes: int, + self, + collector_id: str, + workspace: Path, + artifact_root: Path, + maximum_output_bytes: int, ) -> None: self._collector_id = collector_id self._workspace = workspace.resolve() diff --git a/logicytics/cli.py b/logicytics/cli.py index 1fa796e5..981a3f26 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -14,8 +14,8 @@ from logicytics.configuration import load_config from logicytics.contracts import Capability, RunRequest from logicytics.discovery import preflight -from logicytics.errors import LogicyticsError from logicytics.environment import inspect_environment +from logicytics.errors import LogicyticsError from logicytics.packaging import package_run from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor @@ -27,13 +27,16 @@ def _project_root() -> Path: def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: - profile = "minimal" if getattr(arguments, "minimal", False) else "deep" if getattr(arguments, "depth", False) else "standard" if getattr(arguments, "default_mode", False) or getattr(arguments, "threaded", False) else arguments.profile + profile = "minimal" if getattr(arguments, "minimal", False) else "deep" if getattr(arguments, "depth", + False) else "standard" if getattr( + arguments, "default_mode", False) or getattr(arguments, "threaded", False) else arguments.profile return RunRequest( profile=profile, include=tuple(arguments.include), exclude=tuple(arguments.exclude), enable_plugins=arguments.plugins, - max_workers=1 if getattr(arguments, "performance_check", False) or getattr(arguments, "default_mode", False) else arguments.workers or default_workers, + max_workers=1 if getattr(arguments, "performance_check", False) or getattr(arguments, "default_mode", + False) else arguments.workers or default_workers, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), ) @@ -59,8 +62,10 @@ def _parser() -> argparse.ArgumentParser: ) if command == "run": mode = subparser.add_mutually_exclusive_group() - mode.add_argument("--default", dest="default_mode", action="store_true", help="Run the standard built-in profile.") - mode.add_argument("--threaded", action="store_true", help="Run the standard built-in profile with configured parallel workers.") + mode.add_argument("--default", dest="default_mode", action="store_true", + help="Run the standard built-in profile.") + mode.add_argument("--threaded", action="store_true", + help="Run the standard built-in profile with configured parallel workers.") mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") subparser.add_argument( @@ -69,7 +74,8 @@ def _parser() -> argparse.ArgumentParser: help="Confirm you are authorized to collect the selected evidence.", ) if command == "update": - subparser.add_argument("--apply", action="store_true", help="Explicitly run git pull after repository checks.") + subparser.add_argument("--apply", action="store_true", + help="Explicitly run git pull after repository checks.") subparser.add_argument( "--performance-check", action="store_true", @@ -101,7 +107,11 @@ def main(argv: list[str] | None = None) -> int: payload = { "configuration": configuration.to_manifest_dict(), "environment": inspect_environment().to_dict(), - "python": {"executable": sys.executable, "implementation": platform.python_implementation(), "version": platform.python_version(), "prefix": sys.prefix, "virtual_environment": sys.prefix != sys.base_prefix, "psutil_available": importlib.util.find_spec("psutil") is not None, "cpu_count": os.cpu_count()}, + "python": {"executable": sys.executable, "implementation": platform.python_implementation(), + "version": platform.python_version(), "prefix": sys.prefix, + "virtual_environment": sys.prefix != sys.base_prefix, + "psutil_available": importlib.util.find_spec("psutil") is not None, + "cpu_count": os.cpu_count()}, "sysinternals": ensure_sysinternals(root).to_dict(), "preflight": {"valid_collectors": len(report.valid), "invalid_collectors": len(report.invalid)}, } @@ -110,13 +120,15 @@ def main(argv: list[str] | None = None) -> int: if arguments.command == "update": git = subprocess.run(["git", "--version"], capture_output=True, check=False, text=True) is_repository = (root / ".git").exists() - payload = {"git_available": git.returncode == 0, "git_version": git.stdout.strip() or None, "is_repository": is_repository, "applied": False} + payload = {"git_available": git.returncode == 0, "git_version": git.stdout.strip() or None, + "is_repository": is_repository, "applied": False} if arguments.apply: if git.returncode != 0 or not is_repository: print(json.dumps(payload, indent=2, sort_keys=True)) return 2 pulled = subprocess.run(["git", "pull"], cwd=root, capture_output=True, check=False, text=True) - payload.update({"applied": True, "returncode": pulled.returncode, "stdout": pulled.stdout, "stderr": pulled.stderr}) + payload.update({"applied": True, "returncode": pulled.returncode, "stdout": pulled.stdout, + "stderr": pulled.stderr}) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if not arguments.apply or payload.get("returncode") == 0 else 1 plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) diff --git a/logicytics/command_runner.py b/logicytics/command_runner.py index cdc93937..27623603 100644 --- a/logicytics/command_runner.py +++ b/logicytics/command_runner.py @@ -34,7 +34,8 @@ def parse_level_messages(output: str) -> tuple[tuple[str, str], ...]: for line in output.splitlines(): level, separator, message = line.partition(":") normalized_level = level.strip().upper() - if not separator or normalized_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION"}: + if not separator or normalized_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", + "EXCEPTION"}: continue messages.append((normalized_level, message.strip())) return tuple(messages) diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 29670d3a..9bca78fd 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -9,7 +9,6 @@ from logicytics.errors import PlanError - SCHEMA_VERSION = 4 @@ -44,7 +43,8 @@ def to_manifest_dict(self) -> dict[str, Any]: def default_config(project_root: Path) -> AppConfig: """Create safe defaults rooted at the checked-out project.""" - return AppConfig(schema_version=SCHEMA_VERSION, runtime=RuntimeSettings(output_root=project_root / "ACCESS" / "RUNS")) + return AppConfig(schema_version=SCHEMA_VERSION, + runtime=RuntimeSettings(output_root=project_root / "ACCESS" / "RUNS")) def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: @@ -79,8 +79,8 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( - isinstance(key, str) and isinstance(value, dict) - for key, value in collector_settings.items() + isinstance(key, str) and isinstance(value, dict) + for key, value in collector_settings.items() ): raise PlanError("collectors configuration must map collector IDs to objects") return AppConfig( diff --git a/logicytics/contracts.py b/logicytics/contracts.py index b959ac6f..b046c72c 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -2,11 +2,11 @@ from __future__ import annotations +import re from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field from enum import StrEnum from pathlib import Path -import re from typing import Any, Mapping CONTRACT_VERSION = "4.0" @@ -115,15 +115,16 @@ def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = Fa try: values["specialty"] = Specialty(specialty) except ValueError: - if not allow_custom_specialty or not isinstance(specialty, str) or not _CUSTOM_SPECIALTY.fullmatch(specialty): + if not allow_custom_specialty or not isinstance(specialty, str) or not _CUSTOM_SPECIALTY.fullmatch( + specialty): raise ValueError("collector specialty is unsupported") values["specialty"] = specialty values["capabilities"] = tuple(Capability(capability) for capability in values.get("capabilities", ())) for field_name in ( - "supported_platforms", - "sensitive_data_categories", - "dependencies", - "default_profiles", + "supported_platforms", + "sensitive_data_categories", + "dependencies", + "default_profiles", ): values[field_name] = tuple(values.get(field_name, ())) return cls(**values) @@ -193,14 +194,14 @@ class CollectorContext: """The limited, per-worker interface exposed to a collector.""" def __init__( - self, - run_id: str, - collector_id: str, - workspace: Path, - artifacts: "ArtifactWriter", - logger: EventLogger, - settings: Mapping[str, Any], - cancellation_file: Path, + self, + run_id: str, + collector_id: str, + workspace: Path, + artifacts: "ArtifactWriter", + logger: EventLogger, + settings: Mapping[str, Any], + cancellation_file: Path, ) -> None: self.run_id = run_id self.collector_id = collector_id diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 84788129..a4fd72ec 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -110,9 +110,11 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: elif public_classes[0].name != expected_class: candidate.static_errors.append(f"collector class must be named {expected_class}") elif not any( - (isinstance(base, ast.Name) and base.id == ("CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) - or (isinstance(base, ast.Attribute) and base.attr == ("CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) - for base in public_classes[0].bases + (isinstance(base, ast.Name) and base.id == ( + "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + or (isinstance(base, ast.Attribute) and base.attr == ( + "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + for base in public_classes[0].bases ): candidate.static_errors.append("collector class must inherit from its required base class") elif public_classes: @@ -175,11 +177,13 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat if parameters[0].arg != expected_first: candidate.static_errors.append(f"{name} must begin with {expected_first}") if name == "metadata": - if not any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in method.decorator_list): + if not any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in + method.decorator_list): candidate.static_errors.append("metadata must be a classmethod") else: context_parameter = parameters[1] - annotation = ast.unparse(context_parameter.annotation).rsplit(".", 1)[-1] if context_parameter.annotation else None + annotation = ast.unparse(context_parameter.annotation).rsplit(".", 1)[ + -1] if context_parameter.annotation else None if context_parameter.arg != "context" or annotation != "CollectorContext": candidate.static_errors.append(f"{name} must accept an annotated context parameter") @@ -229,7 +233,8 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: return try: payload = json.loads(completed.stdout) - metadata = CollectorMetadata.from_dict(payload["metadata"], allow_custom_specialty=candidate.kind is CollectorKind.PLUGIN) + metadata = CollectorMetadata.from_dict(payload["metadata"], + allow_custom_specialty=candidate.kind is CollectorKind.PLUGIN) except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: candidate.runtime_error = f"invalid validation response: {error}" return @@ -242,11 +247,11 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: elif candidate.kind is CollectorKind.CORE and metadata.specialty.value != candidate.path.parent.name: candidate.runtime_error = "core collector specialty must match its parent folder" elif candidate.kind is CollectorKind.CORE and metadata.id != ( - f"core.{candidate.path.parent.name}.{candidate.path.stem}" + f"core.{candidate.path.parent.name}.{candidate.path.stem}" ): candidate.runtime_error = "core collector ID must match core//.py" elif candidate.kind is CollectorKind.PLUGIN and metadata.id != ( - f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" + f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" ): candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" else: diff --git a/logicytics/environment.py b/logicytics/environment.py index 16cf87f7..57ca88c0 100644 --- a/logicytics/environment.py +++ b/logicytics/environment.py @@ -29,14 +29,17 @@ def inspect_environment() -> EnvironmentReport: is_administrator = None try: import winreg - with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System") as key: + with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System") as key: uac_enabled = bool(winreg.QueryValueEx(key, "EnableLUA")[0]) except (ImportError, OSError): uac_enabled = None policy = None if which("powershell") is not None: try: - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], capture_output=True, check=False, text=True, timeout=15) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], capture_output=True, + check=False, text=True, timeout=15) if completed.returncode == 0: policy = completed.stdout.strip() or None except OSError: diff --git a/logicytics/file_listing.py b/logicytics/file_listing.py index a337d8ea..f1073641 100644 --- a/logicytics/file_listing.py +++ b/logicytics/file_listing.py @@ -7,16 +7,17 @@ def list_files( - root: Path, - *, - extensions: Iterable[str] = (), - excluded_names: Iterable[str] = (), - excluded_directories: Iterable[str] = (), - append_to: Iterable[Path] = (), + root: Path, + *, + extensions: Iterable[str] = (), + excluded_names: Iterable[str] = (), + excluded_directories: Iterable[str] = (), + append_to: Iterable[Path] = (), ) -> tuple[Path, ...]: """Return sorted normalized files under root, applying explicit filters safely.""" resolved_root = root.resolve() - normalized_extensions = {extension.casefold() if extension.startswith(".") else f".{extension.casefold()}" for extension in extensions} + normalized_extensions = {extension.casefold() if extension.startswith(".") else f".{extension.casefold()}" for + extension in extensions} names = {name.casefold() for name in excluded_names} directories = {name.casefold() for name in excluded_directories} results = {Path(path).resolve() for path in append_to} diff --git a/logicytics/logging.py b/logicytics/logging.py index 1a17105f..5465fec7 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -11,7 +11,6 @@ from logicytics.contracts import EventLogger - Parameters = ParamSpec("Parameters") Result = TypeVar("Result") @@ -41,8 +40,10 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: stream.write(json.dumps(payload, sort_keys=True) + "\n") -def timed(logger: EventLogger, *, level: str = "info") -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: +def timed(logger: EventLogger, *, level: str = "info") -> Callable[ + [Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so structured start, finish, error, and duration events are written.""" + def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: logger.event(level, "function_started", function=function.__qualname__) @@ -50,28 +51,38 @@ def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: try: value = function(*args, **kwargs) except Exception as error: - logger.event("error", "function_failed", function=function.__qualname__, duration_seconds=round(perf_counter() - started, 6), error_type=type(error).__name__) + logger.event("error", "function_failed", function=function.__qualname__, + duration_seconds=round(perf_counter() - started, 6), error_type=type(error).__name__) raise - logger.event(level, "function_finished", function=function.__qualname__, duration_seconds=round(perf_counter() - started, 6)) + logger.event(level, "function_finished", function=function.__qualname__, + duration_seconds=round(perf_counter() - started, 6)) return value + return wrapped + return decorate -def raise_logged(logger: EventLogger, exception_type: type[Exception], message: str, **fields: int | float | str) -> None: +def raise_logged(logger: EventLogger, exception_type: type[Exception], message: str, + **fields: int | float | str) -> None: """Record a structured exception event, then raise the requested exception type.""" logger.event("exception", message, exception_type=exception_type.__name__, **fields) raise exception_type(message) -def deprecated(logger: EventLogger, *, removal_version: str, reason: str, include_stack: bool = False) -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: +def deprecated(logger: EventLogger, *, removal_version: str, reason: str, include_stack: bool = False) -> Callable[ + [Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so each invocation emits a structured deprecation warning.""" + def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: - fields: dict[str, str] = {"function": function.__qualname__, "removal_version": removal_version, "reason": reason} + fields: dict[str, str] = {"function": function.__qualname__, "removal_version": removal_version, + "reason": reason} if include_stack: fields["stack"] = "".join(traceback.format_stack(limit=8)) logger.event("warning", "function_deprecated", **fields) return function(*args, **kwargs) + return wrapped + return decorate diff --git a/logicytics/manifest.py b/logicytics/manifest.py index aa643073..f1ad83ab 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -63,11 +63,11 @@ class RunManifest: @classmethod def create( - cls, - run_id: str, - request: Mapping[str, Any], - configuration: Mapping[str, Any], - collector_sources: list[tuple[str, Path]], + cls, + run_id: str, + request: Mapping[str, Any], + configuration: Mapping[str, Any], + collector_sources: list[tuple[str, Path]], ) -> "RunManifest": """Create the initial planned manifest before collection begins.""" return cls( @@ -76,7 +76,8 @@ def create( status=RunStatus.PLANNED, request=request, configuration=configuration, - collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], + collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in + collector_sources], host={"platform": sys_platform(), "hostname": platform.node(), "python": platform.python_version()}, ) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 79014119..d09e97e2 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import zipfile from pathlib import Path diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 7fe9131f..7328e98c 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -21,12 +21,11 @@ CollectorContext, CollectorResult, CollectorStatus, - RunRequest, RunStatus, ValidationResult, ) -from logicytics.manifest import RunManifest, write_manifest, utc_now from logicytics.logging import FileEventLogger +from logicytics.manifest import RunManifest, write_manifest, utc_now from logicytics.planner import RunPlan @@ -212,16 +211,16 @@ def run(self, plan: RunPlan) -> RunOutcome: return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) def _supervise( - self, - plan: RunPlan, - run_id: str, - workspace_root: Path, - artifact_root: Path, - cancellation_file: Path, - manifest: RunManifest, - manifest_path: Path, - records: dict[str, object], - run_logger: FileEventLogger, + self, + plan: RunPlan, + run_id: str, + workspace_root: Path, + artifact_root: Path, + cancellation_file: Path, + manifest: RunManifest, + manifest_path: Path, + records: dict[str, object], + run_logger: FileEventLogger, ) -> None: """Schedule bounded isolated workers and contain each terminal failure.""" pending = list(plan.collectors) From 91613f1f936ac4d7b511d602fb1d4c56aff32ff1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:22:20 +0300 Subject: [PATCH 081/316] feat: reject direct collector console output --- logicytics/discovery.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index a4fd72ec..0c8c1e2c 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -103,6 +103,8 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: return candidate if ast.get_docstring(tree) is None: candidate.static_errors.append("module requires a docstring") + if any(isinstance(node, ast.Call) and _top_level_call_name(node) == "print" for node in ast.walk(tree)): + candidate.static_errors.append("collectors must not print; use structured progress or logging") public_classes = [item for item in tree.body if isinstance(item, ast.ClassDef) and not item.name.startswith("_")] if len(public_classes) != 1: From b1318fa981a2364071a8bc288074bdd58fd5bbee Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:24:44 +0300 Subject: [PATCH 082/316] test: cover collector console output rejection --- tests/test_core_functionality.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index ea8758b2..d4ac8eea 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -232,6 +232,21 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) + def test_preflight_rejects_collector_print_calls(self) -> None: + """Collectors must emit structured events rather than write directly to stdout.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace("return CollectorResult.succeeded(\"test artifact created\", (artifact,))", "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("collectors must not print; use structured progress or logging", report.invalid[0].static_errors) + def test_capability_gate_requires_explicit_approval(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From a259ceecb57fbfb52d724e8cdcc7ecf4cd0a8e7e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:27:51 +0300 Subject: [PATCH 083/316] feat: add collection estimate contract --- logicytics/__init__.py | 2 ++ logicytics/contracts.py | 20 ++++++++++++++++++++ logicytics/discovery.py | 1 + tests/test_core_functionality.py | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 7f120eb8..ec8422bf 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -4,6 +4,7 @@ CONTRACT_VERSION, Capability, Collector, + CollectionEstimate, CollectorMetadata, CollectorResult, CoreCollector, @@ -17,6 +18,7 @@ "CONTRACT_VERSION", "Capability", "Collector", + "CollectionEstimate", "CollectorMetadata", "CollectorResult", "CoreCollector", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index b046c72c..1ac35bc2 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -154,6 +154,22 @@ class ValidationResult: warnings: tuple[str, ...] = () +@dataclass(frozen=True, slots=True) +class CollectionEstimate: + """A collector's optional, side-effect-free resource estimate.""" + + estimated_seconds: float + estimated_output_bytes: int + notes: tuple[str, ...] = () + + def __post_init__(self) -> None: + """Reject estimates that cannot describe a possible collection run.""" + if self.estimated_seconds < 0: + raise ValueError("estimated_seconds must not be negative") + if self.estimated_output_bytes < 0: + raise ValueError("estimated_output_bytes must not be negative") + + @dataclass(frozen=True, slots=True) class CollectorResult: """The normalized result returned by an isolated collector worker.""" @@ -245,6 +261,10 @@ def validate(self, context: CollectorContext) -> ValidationResult: def collect(self, context: CollectorContext) -> CollectorResult: """Collect evidence and return a normalized result.""" + def estimate(self, context: CollectorContext) -> CollectionEstimate: + """Optionally estimate time and output without collecting evidence.""" + return CollectionEstimate(estimated_seconds=0, estimated_output_bytes=0) + def cleanup(self, context: CollectorContext) -> None: """Release collector-local resources. The engine owns filesystem cleanup.""" diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 0c8c1e2c..edc4a2a2 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -158,6 +158,7 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat "metadata": "CollectorMetadata", "validate": "ValidationResult", "collect": "CollectorResult", + "estimate": "CollectionEstimate", "cleanup": "None", } missing = sorted(required - set(methods)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d4ac8eea..176fc2a4 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -232,6 +232,27 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) + def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: + """Optional estimates use the same strict typed contract as lifecycle methods.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + " def cleanup(self, context: CollectorContext) -> None:", + " def estimate(self, context: CollectorContext) -> ValidationResult:\n" + " \"\"\"Return an invalid estimate type for this test.\"\"\"\n" + " return ValidationResult(valid=True)\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("estimate must return CollectionEstimate", report.invalid[0].static_errors) + def test_preflight_rejects_collector_print_calls(self) -> None: """Collectors must emit structured events rather than write directly to stdout.""" with tempfile.TemporaryDirectory() as temporary: From 48ab5a6badd0584713c639fda4dcf5f3947c2515 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:31:50 +0300 Subject: [PATCH 084/316] feat: add scheduled task collector --- core/system/scheduled_tasks.py | 85 ++++++++++++++++++++++++++++++++ logicytics/cli.py | 5 ++ tests/test_core_functionality.py | 6 +++ tests/test_shipped_collectors.py | 1 + 4 files changed, 97 insertions(+) create mode 100644 core/system/scheduled_tasks.py diff --git a/core/system/scheduled_tasks.py b/core/system/scheduled_tasks.py new file mode 100644 index 00000000..31bac5db --- /dev/null +++ b/core/system/scheduled_tasks.py @@ -0,0 +1,85 @@ +"""Export bounded local Scheduled Task metadata as read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class ScheduledTasksCollector(CoreCollector): + """Capture read-only local scheduled-task identities and current states.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated scheduled-task JSON artifact contract.""" + return CollectorMetadata( + id="core.system.scheduled_tasks", name="Scheduled tasks", version="4.0.0", specialty=Specialty.SYSTEM, + description="Exports up to 1,000 local scheduled-task names, paths, authors, descriptions, and states.", + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=2 * 1024 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query bounded scheduled-task metadata and register a JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before scheduled-task collection") + context.report_progress("scheduled_tasks_started") + command = ( + "Get-ScheduledTask | Select-Object -First 1000 TaskName, TaskPath, State, Author, Description, URI | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=55, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult( + CollectorStatus.SKIPPED, + "scheduled-task access was denied for the current account", + errors=(detail,), + ) + return CollectorResult(CollectorStatus.FAILED, "scheduled-task query failed", errors=(detail,)) + try: + tasks = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult( + CollectorStatus.FAILED, + "scheduled-task query returned invalid JSON", + errors=(str(error),), + ) + if not isinstance(tasks, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "scheduled-task query returned an unexpected result") + output = context.workspace / "scheduled_tasks.json" + output.write_text(json.dumps(tasks, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(tasks) if isinstance(tasks, list) else 1 + context.report_progress("scheduled_tasks_finished", task_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("scheduled tasks collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/logicytics/cli.py b/logicytics/cli.py index 981a3f26..eac5750c 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -68,6 +68,11 @@ def _parser() -> argparse.ArgumentParser: help="Run the standard built-in profile with configured parallel workers.") mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") + subparser.add_argument( + "--performance-check", + action="store_true", + help="Run serially and save per-collector duration measurements.", + ) subparser.add_argument( "--acknowledge-authorization", action="store_true", diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 176fc2a4..a0d7fd1a 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -9,6 +9,7 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command +from logicytics.cli import _parser from logicytics.file_listing import list_files from logicytics.logging import deprecated, raise_logged, timed from logicytics.sysinternals import ensure_sysinternals @@ -151,6 +152,11 @@ def test_configuration_schema_version_is_enforced(self) -> None: config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") self.assertEqual(4, load_config(root).schema_version) + def test_run_parser_accepts_performance_check(self) -> None: + """The run command must expose the performance mode used by the request builder.""" + arguments = _parser().parse_args(["run", "--performance-check"]) + self.assertTrue(arguments.performance_check) + """Validate the foundation before real core collectors are added.""" def test_artifacts_cannot_escape_collector_workspace(self) -> None: diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index a3731177..f3d3beec 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -59,6 +59,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.system_diagnostics", "core.system.installed_drivers", "core.system.installed_updates", + "core.system.scheduled_tasks", "core.system.windows_services", "core.system.windows_system_data_backup", "core.diagnostics.sysinternals_report", From 76f90b7860a99bf2d0ada9576e1e8cbd305214b0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:34:01 +0300 Subject: [PATCH 085/316] feat: add startup folder collector --- core/filesystem/startup_folder_entries.py | 95 +++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 96 insertions(+) create mode 100644 core/filesystem/startup_folder_entries.py diff --git a/core/filesystem/startup_folder_entries.py b/core/filesystem/startup_folder_entries.py new file mode 100644 index 00000000..3d6126d8 --- /dev/null +++ b/core/filesystem/startup_folder_entries.py @@ -0,0 +1,95 @@ +"""Export bounded metadata for files in the standard Windows Startup folders.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + +_MAXIMUM_ENTRIES = 500 + + +def _startup_folders() -> tuple[Path, ...]: + """Return the standard per-user and all-users Startup folders without creating them.""" + folders: list[Path] = [] + appdata = os.environ.get("APPDATA") + programdata = os.environ.get("PROGRAMDATA") + if appdata: + folders.append(Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup") + if programdata: + folders.append(Path(programdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Startup") + return tuple(folders) + + +class StartupFolderEntriesCollector(CoreCollector): + """Capture read-only metadata for entries configured to start through Startup folders.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the filesystem-read, bounded Startup-folder JSON artifact contract.""" + return CollectorMetadata( + id="core.filesystem.startup_folder_entries", + name="Startup folder entries", + version="4.0.0", + specialty=Specialty.FILESYSTEM, + description="Exports names, locations, sizes, and timestamps for up to 500 Startup-folder entries.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and whether at least one standard folder is available.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if not _startup_folders(): + return ValidationResult(False, reasons=("Windows Startup-folder environment variables are unavailable",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Record bounded metadata for direct files in existing Startup folders.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Startup-folder collection") + context.report_progress("startup_folder_entries_started") + entries: list[dict[str, int | str]] = [] + missing_folders = 0 + for folder in _startup_folders(): + if not folder.is_dir(): + missing_folders += 1 + continue + try: + children = sorted(folder.iterdir(), key=lambda path: path.name.casefold()) + except OSError as error: + return CollectorResult(CollectorStatus.SKIPPED, "Startup-folder access was denied", errors=(str(error),)) + for child in children: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Startup-folder collection") + if len(entries) >= _MAXIMUM_ENTRIES: + break + try: + stat = child.stat() + except OSError: + continue + entries.append({ + "name": child.name, + "path": str(child), + "is_directory": str(child.is_dir()).lower(), + "size_bytes": stat.st_size, + "modified_epoch": int(stat.st_mtime), + }) + output = context.workspace / "startup_folder_entries.json" + output.write_text(json.dumps({"entries": entries, "missing_folders": missing_folders}, indent=2) + "\n", + encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("startup_folder_entries_finished", entry_count=len(entries), bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Startup-folder entries collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because collection only reads directory metadata.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index f3d3beec..57bafa66 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -74,6 +74,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.event_log.application_events", "core.event_log.security_events", "core.filesystem.system_drive_tree", + "core.filesystem.startup_folder_entries", "core.filesystem.sensitive_file_inventory", "core.filesystem.system_drive_listing", "core.encryption.bitlocker_status", From 316182ab78073da34e70106df2ea5e5e760f23ad Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:35:56 +0300 Subject: [PATCH 086/316] feat: add defender status collector --- core/system/defender_status.py | 89 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 90 insertions(+) create mode 100644 core/system/defender_status.py diff --git a/core/system/defender_status.py b/core/system/defender_status.py new file mode 100644 index 00000000..c1ab5acc --- /dev/null +++ b/core/system/defender_status.py @@ -0,0 +1,89 @@ +"""Export local Microsoft Defender protection status as read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_unavailable(detail: str) -> bool: + """Recognize missing Defender-provider and access-denied results without failing a run.""" + normalized = detail.casefold() + return ( + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "not recognized" in normalized + or "cannot find" in normalized + ) + + +class DefenderStatusCollector(CoreCollector): + """Capture read-only Microsoft Defender configuration and protection-state metadata.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated Defender-status JSON artifact contract.""" + return CollectorMetadata( + id="core.system.defender_status", + name="Microsoft Defender status", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports installed Microsoft Defender engine, signature, and protection-state metadata.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("security_configuration",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=128 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query Defender status and register its bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Defender-status collection") + context.report_progress("defender_status_started") + command = ( + "Get-MpComputerStatus | Select-Object AMEngineVersion, AMProductVersion, AMServiceEnabled, " + "AntispywareEnabled, AntivirusEnabled, BehaviorMonitorEnabled, IoavProtectionEnabled, " + "NISEnabled, RealTimeProtectionEnabled, AntivirusSignatureVersion, AntivirusSignatureLastUpdated, " + "QuickScanAge, FullScanAge | ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_unavailable(detail): + return CollectorResult(CollectorStatus.SKIPPED, "Microsoft Defender status is unavailable", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "Microsoft Defender status query failed", errors=(detail,)) + try: + status = json.loads(completed.stdout) if completed.stdout.strip() else {} + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "Defender status query returned invalid JSON", errors=(str(error),)) + if not isinstance(status, dict): + return CollectorResult(CollectorStatus.FAILED, "Defender status query returned an unexpected result") + output = context.workspace / "defender_status.json" + output.write_text(json.dumps(status, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + context.report_progress("defender_status_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("Microsoft Defender status collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 57bafa66..3a72a5c4 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -54,6 +54,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.bios_info", "core.system.operating_system", "core.system.computer_system", + "core.system.defender_status", "core.system.session_snapshot", "core.system.environment_posture", "core.system.system_diagnostics", From e28566be3e6946138f96384c579358bc380f4da0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:38:11 +0300 Subject: [PATCH 087/316] feat: validate collector dependencies --- logicytics/contracts.py | 5 +++++ logicytics/discovery.py | 11 ++++++----- logicytics/validation_worker.py | 6 ++++++ tests/test_core_functionality.py | 21 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 1ac35bc2..471f7789 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -265,6 +265,11 @@ def estimate(self, context: CollectorContext) -> CollectionEstimate: """Optionally estimate time and output without collecting evidence.""" return CollectionEstimate(estimated_seconds=0, estimated_output_bytes=0) + @classmethod + def dependencies(cls) -> tuple[str, ...]: + """Optionally declare collector IDs that must complete before this collector.""" + return () + def cleanup(self, context: CollectorContext) -> None: """Release collector-local resources. The engine owns filesystem cleanup.""" diff --git a/logicytics/discovery.py b/logicytics/discovery.py index edc4a2a2..ad142f7e 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -159,6 +159,7 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat "validate": "ValidationResult", "collect": "CollectorResult", "estimate": "CollectionEstimate", + "dependencies": "tuple[str,...]", "cleanup": "None", } missing = sorted(required - set(methods)) @@ -169,20 +170,20 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat candidate.static_errors.append(f"{name} requires a docstring") if method.returns is None: candidate.static_errors.append(f"{name} requires a return type annotation") - elif name in expected_returns and ast.unparse(method.returns).rsplit(".", 1)[-1] != expected_returns[name]: + elif name in expected_returns and ast.unparse(method.returns).replace(" ", "") != expected_returns[name]: candidate.static_errors.append(f"{name} must return {expected_returns[name]}") parameters = method.args.args - expected_parameter_count = 1 if name == "metadata" else 2 + expected_parameter_count = 1 if name in {"metadata", "dependencies"} else 2 if len(parameters) != expected_parameter_count: candidate.static_errors.append(f"{name} has an invalid parameter count") continue - expected_first = "cls" if name == "metadata" else "self" + expected_first = "cls" if name in {"metadata", "dependencies"} else "self" if parameters[0].arg != expected_first: candidate.static_errors.append(f"{name} must begin with {expected_first}") - if name == "metadata": + if name in {"metadata", "dependencies"}: if not any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in method.decorator_list): - candidate.static_errors.append("metadata must be a classmethod") + candidate.static_errors.append(f"{name} must be a classmethod") else: context_parameter = parameters[1] annotation = ast.unparse(context_parameter.annotation).rsplit(".", 1)[ diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index e0aecb8e..f145cbfc 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -41,6 +41,12 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> parameters = parameters[1:] if len(parameters) != expected_parameters: raise ValueError(f"{method_name} has an invalid signature") + dependencies = getattr(collector_type, "dependencies", None) + if dependencies is None or list(inspect.signature(dependencies).parameters.values()): + raise ValueError("dependencies has an invalid signature") + declared_dependencies = dependencies() + if not isinstance(declared_dependencies, tuple) or not all(isinstance(item, str) for item in declared_dependencies): + raise ValueError("dependencies() must return tuple[str, ...]") metadata = collector_type.metadata() if not isinstance(metadata, CollectorMetadata): raise ValueError("metadata() must return CollectorMetadata") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a0d7fd1a..d3f0eeb8 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -259,6 +259,27 @@ def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("estimate must return CollectionEstimate", report.invalid[0].static_errors) + def test_preflight_rejects_invalid_dependencies_contract(self) -> None: + """Optional dependency declarations must remain typed class-level metadata.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + " def cleanup(self, context: CollectorContext) -> None:", + " def dependencies(self) -> tuple[str, ...]:\n" + " \"\"\"Return an invalid instance-level dependency declaration.\"\"\"\n" + " return ()\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("dependencies must be a classmethod", report.invalid[0].static_errors) + def test_preflight_rejects_collector_print_calls(self) -> None: """Collectors must emit structured events rather than write directly to stdout.""" with tempfile.TemporaryDirectory() as temporary: From 0d2fea34658cc5f832989b661f5060f66d0a60ca Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:39:50 +0300 Subject: [PATCH 088/316] feat: add local accounts collector --- core/system/local_accounts.py | 83 ++++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 84 insertions(+) create mode 100644 core/system/local_accounts.py diff --git a/core/system/local_accounts.py b/core/system/local_accounts.py new file mode 100644 index 00000000..d7ff1111 --- /dev/null +++ b/core/system/local_accounts.py @@ -0,0 +1,83 @@ +"""Export local Windows account metadata without accessing credentials or secrets.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +def _is_access_denied(detail: str) -> bool: + """Recognize common local-account permission-denied wording from PowerShell output.""" + normalized = detail.casefold() + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) + + +class LocalAccountsCollector(CoreCollector): + """Capture read-only local-account names, enabled state, and timestamps through PowerShell.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated local-account JSON artifact contract.""" + return CollectorMetadata( + id="core.system.local_accounts", + name="Local accounts", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Exports local account names, SIDs, enabled state, descriptions, and login timestamps only.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("account_metadata",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query local account metadata and register a bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before local-account collection") + context.report_progress("local_accounts_started") + command = ( + "Get-LocalUser | Select-Object Name, Enabled, Description, SID, LastLogon, PasswordLastSet, " + "UserMayChangePassword | ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + if _is_access_denied(detail): + return CollectorResult(CollectorStatus.SKIPPED, "local-account access was denied", errors=(detail,)) + return CollectorResult(CollectorStatus.FAILED, "local-account query failed", errors=(detail,)) + try: + accounts = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "local-account query returned invalid JSON", errors=(str(error),)) + if not isinstance(accounts, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "local-account query returned an unexpected result") + output = context.workspace / "local_accounts.json" + output.write_text(json.dumps(accounts, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(accounts) if isinstance(accounts, list) else 1 + context.report_progress("local_accounts_finished", account_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("local accounts collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 3a72a5c4..a23c4518 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -60,6 +60,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.system_diagnostics", "core.system.installed_drivers", "core.system.installed_updates", + "core.system.local_accounts", "core.system.scheduled_tasks", "core.system.windows_services", "core.system.windows_system_data_backup", From 57ee7df2bef64ea4c7fc7f30cedda536904836d3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:41:55 +0300 Subject: [PATCH 089/316] feat: probe collector validation during preflight --- logicytics/validation_worker.py | 44 +++++++++++++++++++++++++++++++- tests/test_core_functionality.py | 15 +++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index f145cbfc..e41bc671 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -6,9 +6,33 @@ import inspect import json import sys +import tempfile from pathlib import Path -from logicytics.contracts import Collector, CollectorKind, CollectorMetadata, CoreCollector, PluginCollector +from logicytics.contracts import ( + Collector, + CollectorContext, + CollectorKind, + CollectorMetadata, + CoreCollector, + PluginCollector, + ValidationResult, +) + + +class _ProbeLogger: + """Discard structured events emitted during the side-effect-free validation probe.""" + + def event(self, level: str, message: str, **fields: int | float | str) -> None: + """Accept probe events without exposing them through the JSON-only worker output.""" + + +class _ProbeArtifactWriter: + """Reject evidence registration during preflight validation.""" + + def register_file(self, source: Path, *, media_type: str = "application/octet-stream"): + """Prevent a validation method from registering collection artifacts.""" + raise RuntimeError("validate() must not register artifacts") def _load_module(path: Path): @@ -50,6 +74,24 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> metadata = collector_type.metadata() if not isinstance(metadata, CollectorMetadata): raise ValueError("metadata() must return CollectorMetadata") + collector = collector_type() + with tempfile.TemporaryDirectory(prefix="logicytics-validation-") as temporary: + workspace = Path(temporary) + context = CollectorContext( + run_id="validation", + collector_id=metadata.id, + workspace=workspace, + artifacts=_ProbeArtifactWriter(), + logger=_ProbeLogger(), + settings={}, + cancellation_file=workspace / "cancelled", + ) + try: + validation = collector.validate(context) + except Exception as error: + raise ValueError(f"validate() probe failed: {error}") from error + if not isinstance(validation, ValidationResult): + raise ValueError("validate() must return ValidationResult") return metadata diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d3f0eeb8..5f8ab9ff 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -280,6 +280,21 @@ def test_preflight_rejects_invalid_dependencies_contract(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("dependencies must be a classmethod", report.invalid[0].static_errors) + def test_preflight_rejects_malformed_validate_result(self) -> None: + """The isolated preflight probe must reject a non-ValidationResult response.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace("return ValidationResult(True)", "return CollectorResult.succeeded('invalid')"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("validate() must return ValidationResult", report.invalid[0].runtime_error or "") + def test_preflight_rejects_collector_print_calls(self) -> None: """Collectors must emit structured events rather than write directly to stdout.""" with tempfile.TemporaryDirectory() as temporary: From 477d439a506c8028bde939d2424d41b3979dc534 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:43:31 +0300 Subject: [PATCH 090/316] fix: require dependency declaration consistency --- logicytics/validation_worker.py | 2 ++ tests/test_core_functionality.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index e41bc671..a01d3e05 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -74,6 +74,8 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> metadata = collector_type.metadata() if not isinstance(metadata, CollectorMetadata): raise ValueError("metadata() must return CollectorMetadata") + if declared_dependencies != metadata.dependencies: + raise ValueError("dependencies() must match metadata.dependencies") collector = collector_type() with tempfile.TemporaryDirectory(prefix="logicytics-validation-") as temporary: workspace = Path(temporary) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 5f8ab9ff..8ad52941 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -280,6 +280,28 @@ def test_preflight_rejects_invalid_dependencies_contract(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("dependencies must be a classmethod", report.invalid[0].static_errors) + def test_preflight_rejects_dependency_declaration_mismatch(self) -> None: + """The planner-facing metadata and optional dependency method must agree.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + " def cleanup(self, context: CollectorContext) -> None:", + " @classmethod\n" + " def dependencies(cls) -> tuple[str, ...]:\n" + " \"\"\"Return a dependency absent from metadata for this test.\"\"\"\n" + " return ('core.system.missing',)\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("dependencies() must match metadata.dependencies", report.invalid[0].runtime_error or "") + def test_preflight_rejects_malformed_validate_result(self) -> None: """The isolated preflight probe must reject a non-ValidationResult response.""" with tempfile.TemporaryDirectory() as temporary: From d99610e7d867ed3ca48ef53948a56dc0881768a1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:44:44 +0300 Subject: [PATCH 091/316] feat: add display adapter collector --- core/hardware/display_adapters.py | 65 +++++++++++++++++++++++++++++++ tests/test_shipped_collectors.py | 1 + 2 files changed, 66 insertions(+) create mode 100644 core/hardware/display_adapters.py diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py new file mode 100644 index 00000000..1ee2d488 --- /dev/null +++ b/core/hardware/display_adapters.py @@ -0,0 +1,65 @@ +"""Export local display-adapter metadata as bounded, read-only JSON evidence.""" + +from __future__ import annotations + +import json +import subprocess +from shutil import which + +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus + + +class DisplayAdaptersCollector(CoreCollector): + """Capture read-only local display-adapter identities, drivers, and memory through CIM.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the subprocess-gated display-adapter JSON artifact contract.""" + return CollectorMetadata( + id="core.hardware.display_adapters", name="Display adapters", version="4.0.0", specialty=Specialty.HARDWARE, + description="Exports local display-adapter names, driver versions, resolution, and memory metadata.", + author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("hardware_inventory",), default_profiles=("deep",), timeout_seconds=30, + maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Check cancellation state and PowerShell availability before collection.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("powershell") is None: + return ValidationResult(False, reasons=("PowerShell is unavailable on this system",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Query display-adapter CIM metadata and register a bounded JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before display-adapter collection") + context.report_progress("display_adapters_started") + command = ( + "Get-CimInstance Win32_VideoController | Select-Object Name, AdapterCompatibility, DriverVersion, " + "VideoProcessor, AdapterRAM, CurrentHorizontalResolution, CurrentVerticalResolution, CurrentRefreshRate " + "| ConvertTo-Json -Depth 3" + ) + completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, check=False, text=True, timeout=25) + if completed.returncode != 0: + detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" + status = CollectorStatus.SKIPPED if "denied" in detail.casefold() else CollectorStatus.FAILED + return CollectorResult(status, "display-adapter query failed", errors=(detail,)) + try: + adapters = json.loads(completed.stdout) if completed.stdout.strip() else [] + except json.JSONDecodeError as error: + return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned invalid JSON", errors=(str(error),)) + if not isinstance(adapters, (dict, list)): + return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned an unexpected result") + output = context.workspace / "display_adapters.json" + output.write_text(json.dumps(adapters, indent=2, sort_keys=True) + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="application/json") + count = len(adapters) if isinstance(adapters, list) else 1 + context.report_progress("display_adapters_finished", adapter_count=count, bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("display adapters collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because PowerShell exits before the result is returned.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index a23c4518..c2e15de7 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -71,6 +71,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.wireless.wifi_interfaces", "core.wireless.wifi_profile_keys", "core.hardware.windows_features", + "core.hardware.display_adapters", "core.hardware.battery_status", "core.event_log.system_events", "core.event_log.application_events", From bdf5786c4bf4925714d594708925931a3d84c870 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:45:23 +0300 Subject: [PATCH 092/316] docs: track v4 recreation plan --- .gitignore | 3 +- TODO.md | 938 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 939 insertions(+), 2 deletions(-) create mode 100644 TODO.md diff --git a/.gitignore b/.gitignore index 9e544d9c..f07ec08a 100644 --- a/.gitignore +++ b/.gitignore @@ -328,5 +328,4 @@ $RECYCLE.BIN/ /CODE/SysInternal_Suite/pslist.exe /CODE/SysInternal_Suite/PsLoggedon.exe /CODE/SysInternal_Suite/psloglist.exe -TODO.md -.idea/ \ No newline at end of file +.idea/ diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..f1ce6727 --- /dev/null +++ b/TODO.md @@ -0,0 +1,938 @@ +# Logicytics v4.0 Recreation TODO + +This is the complete feature inventory and recreation plan for Logicytics v4.0. +The checklist describes what Logicytics does and what v4.0 must provide; it does +not describe the quality of the current implementation. + +## Core product redesign — mandatory v4.0 change + +The v4.0 recreation must not be a collection of scripts launched from a large +dispatcher. The core idea must change to a run-oriented evidence pipeline: + +`request -> validated plan -> isolated collectors -> normalized artifacts -> manifest -> package` + +Every feature in this file remains in scope, but each feature must fit that +pipeline. A collector is a producer of evidence, not an application that decides +where the whole product stores files, how the process exits, or how another +collector is launched. + +### Core functionality status + +- [x] v4 project metadata and standard-library-only engine foundation. +- [x] Typed collector contracts, metadata, statuses, run requests, validation, and + artifact interfaces. +- [x] Typed local configuration with validated worker and output limits. +- [x] Strict discovery/preflight for `core/` and `plugins/`, including filename, + class, inheritance, method, docstring, annotation, metadata, specialty, and + ID validation. +- [x] Short-lived isolated metadata probe and invalid-plugin quarantine policy. +- [x] Deterministic profile/include/exclude planning with dependency ordering. +- [x] One worker process and private workspace per collector, with per-collector + timeout containment and independent failure results. +- [x] Workspace-bound artifact registration, size limits, checksums, run manifests, + ZIP creation, and package-level SHA-256 sidecars. +- [x] `python -m logicytics` commands for preflight, planning, and supervised runs. +- [x] Core integration tests for strict preflight, quarantine, isolated execution, + artifact boundaries, manifests, and packaging. +- [x] Structured engine/collector JSONL logging, capability enforcement, + cancellation propagation, Windows process-tree cleanup, and per-collector + duration/progress/artifact accounting. +- [x] Add the first actual shipped collector: `core.system.system_info`, a bounded + non-sensitive system inventory that establishes the strict collector pattern. +- [x] Add `core.process.running_processes`, a capability-gated, bounded, + non-verbose Tasklist CSV collector that skips expected access denials without + affecting unrelated collectors. +- [x] Add `core.network.network_identity`, a capability-gated hostname/address + report that does not probe remote hosts. +- [x] Add `core.memory.memory_snapshot`, a capability-free Windows aggregate + physical, virtual, and page-file memory report. +- [x] Add `core.storage.logical_drives`, a capability-free logical-drive type and + capacity report that does not enumerate file contents. +- [x] Add `core.hardware.windows_features`, a deep-profile, subprocess-gated + optional-Windows-feature inventory. +- [x] Add `core.event_log.system_events`, a deep-profile, subprocess-gated, + bounded System event-log CSV export. +- [x] Add `core.network.arp_cache`, a deep-profile, subprocess-gated, read-only + ARP-cache report that sends no network traffic. +- [x] Add `core.network.routing_table`, a deep-profile, subprocess-gated, + read-only IPv4 and IPv6 routing-table report. +- [x] Add `core.system.system_details`, a deep-profile, subprocess-gated, + complete `systeminfo` text report. +- [x] Add `core.system.bios_info`, a deep-profile, subprocess-gated BIOS CIM + inventory that renders a portable HTML evidence table. +- [x] Add `core.system.operating_system`, a deep-profile, subprocess-gated CIM + report with OS caption, version, service-pack, language, and path details. +- [x] Add `core.system.computer_system`, a deep-profile, subprocess-gated CIM + report with computer model, manufacturer, and processor counts. +- [x] Add `core.storage.physical_disks`, a deep-profile, subprocess-gated CIM + inventory of physical disk models, interface types, and capacities. +- [x] Add `core.system.session_snapshot`, a deep-profile, subprocess-gated report + with Windows build, memory, current user/SID, language, host, time, and drive. +- [x] Add `core.process.detailed_processes`, a deep-profile, subprocess-gated, + bounded verbose Tasklist CSV report. +- [x] Add `core.network.active_connections`, a deep-profile, subprocess-gated, + bounded Netstat report with endpoints, protocol, state, and owning PID. +- [x] Add `core.network.adapter_statistics`, a deep-profile, subprocess-gated, + per-interface byte, packet, error, and discard-counter inventory. +- [x] Add `core.network.network_interfaces`, a deep-profile, subprocess-gated + IPv4 address, netmask, broadcast, link-state, speed, and duplex inventory. +- [x] Add `core.network.connection_processes`, a deep-profile, subprocess-gated + Netstat and Tasklist correlation report for connection process names and PIDs. +- [x] Add `core.wireless.wifi_profiles`, a deep-profile, subprocess-gated, + saved Wi-Fi profile-name inventory that never reads key material. +- [x] Add `core.event_log.application_events`, a deep-profile, subprocess-gated, + bounded Application event-log CSV export. +- [x] Add `core.storage.mounted_volumes`, a deep-profile, subprocess-gated, + mounted volume-GUID and mount-point mapping report. +- [x] Add `core.encryption.bitlocker_status`, a deep-profile, subprocess-gated, + read-only `manage-bde -status` report. +- [x] Add `core.encryption.bitlocker_volumes`, a deep-profile, subprocess-gated + `Get-BitLockerVolume` JSON report. +- [x] Add `core.system.system_diagnostics`, a deep-profile, subprocess-gated + architecture, CPU, page-size, and boot-time report. +- [x] Add `core.event_log.security_events`, a deep-profile, subprocess-gated, + bounded Security event-log CSV export. +- [x] Add `core.process.process_memory`, a deep-profile, subprocess-gated + aggregate per-process working-set, private, and virtual memory report. +- [x] Add `core.filesystem.system_drive_tree`, a deep-profile, filesystem-read, + bounded recursive system-drive tree with configurable depth and entry limits. +- [x] Add `core.filesystem.system_drive_listing`, a deep-profile, filesystem-read, + bounded threaded recursive system-drive listing. +- [x] Add `core.network.bandwidth_sample`, a deep-profile, subprocess-gated, + configurable local adapter bandwidth sampler. +- [x] Add `core.bluetooth.bluetooth_addresses`, a deep-profile, subprocess-gated, + paired Bluetooth name and address-like PnP identifier report. +- [x] Add `core.system.installed_updates`, a deep-profile, subprocess-gated + installed Windows hotfix inventory. +- [x] Add `core.registry.installed_applications`, a deep-profile, registry-read + installed application inventory across 64-bit and WOW6432Node uninstall keys. +- [x] Add `core.system.windows_services`, a deep-profile, subprocess-gated Windows + service inventory with state, startup, account, path, and process metadata. +- [x] Add `core.registry.startup_applications`, a deep-profile, registry-read + inventory of standard user and machine Run/RunOnce startup entries. +- [x] Add `core.hardware.battery_status`, a deep-profile, subprocess-gated battery + charge, capacity, and runtime inventory. +- [x] Add `core.network.dns_cache`, a deep-profile, subprocess-gated sensitive DNS + resolver-cache export that sends no network traffic. +- [x] Add `core.network.firewall_profiles`, a deep-profile, subprocess-gated + read-only Domain, Private, and Public firewall-profile inventory. +- [x] Add `core.system.environment_posture`, a deep-profile, subprocess-gated + read-only administrator, UAC, and PowerShell execution-policy report. +- [x] Add `core.wireless.wifi_interfaces`, a deep-profile, subprocess-gated + Wi-Fi interface report with normalized `Wi-Fi`/`WiFi` interface names. +- [x] Add `core.wireless.wifi_profile_keys`, an explicitly sensitive-data-gated + deep collector that exports saved Wi-Fi profile XML with key material. +- [x] Add `core.registry.hklm_backup`, an explicitly sensitive-data-gated deep + collector that exports the local HKLM hive as a `.reg` backup. +- [x] Add `core.ssh.ssh_backup`, an explicitly sensitive-data-gated deep collector + that archives the current user's bounded `.ssh` keys and configuration. +- [x] Add `core.media.media_backup`, an explicitly sensitive-data-gated deep collector + that copies bounded JPG, JPEG, PNG, and MP4 evidence from Pictures and Videos. +- [x] Add `core.browser.browser_data_backup`, an explicitly sensitive-data-gated deep + collector for bounded Edge, Chrome, Firefox, Opera, and Opera GX profile evidence. +- [x] Add `core.bluetooth.bluetooth_history`, a timestamped deep snapshot whose + retained run artifacts provide isolated Bluetooth history evidence. +- [x] Add `core.packet.packet_capture`, an explicitly packet-capture/elevation-gated + IPv4 metadata collector with configurable count and timeout settings. +- [x] Add `core.filesystem.sensitive_file_inventory`, an explicitly sensitive-data-gated + deep collector that finds supported sensitive-named files and copies matches concurrently. +- [x] Add `core.system.windows_system_data_backup`, an explicitly sensitive-data-gated + deep collector that copies bounded Group Policy, event-log, and Defender support data. +- [x] Add `core.packet.connection_graph`, a deep subprocess-gated DOT export of + source/destination connection edges with protocol labels. +- [x] Add `core.diagnostics.sysinternals_report`, a deep collector that reports + Sysinternals archive/binary state and consolidates available supported tool output. +- [x] Add `core.process.memory_map`, a deep metadata-only memory-region collector + with bounded readable addresses, sizes, permissions, mapped paths, and indexes. +- [x] Add `core.storage.volume_details`, a capability-free detailed logical-volume + report with type, filesystem, label, free space, and capacity. +- [x] Add `core.network.network_adapters`, a subprocess-gated `ipconfig /all` + adapter inventory. +- [x] Add `core.bluetooth.paired_devices`, a deep-profile, subprocess-gated, + read-only PnP Bluetooth device inventory. +- [x] Add `core.usb.usb_storage_inventory`, a deep-profile, registry-read-gated + USB storage inventory. +- [x] Add `core.system.installed_drivers`, a deep-profile, subprocess-gated + detailed Windows driver inventory. +- [x] Add `core.system.group_policy`, a deep-profile, subprocess-gated local + Group Policy Result summary. + +## Core and plugin layout — canonical v4.0 model + +The shipped product and user extensions must be physically and conceptually +separate: + +```text +Logicytics/ +├── core/ # shipped, trusted-by-default collectors +│ ├── system/ +│ │ └── system_info.py +│ ├── network/ +│ │ └── network_info.py +│ └── ... +├── plugins/ # user-added collectors and integrations +│ ├── example_plugin.py +│ └── ... +└── logicytics/ # engine, contracts, planner, runtime +``` + +- [x] Make `core/` the only source of shipped collection functionality. +- [x] Automatically discover and validate every eligible collector in `core/` at + startup; core collectors are included by the selected built-in profile unless + a profile explicitly excludes them. +- [x] Make `plugins/` an opt-in user extension area. Discover plugins automatically + during preflight, but only run valid plugins when the user selects the plugin + profile/flag or explicitly includes them. +- [x] Permit plugins to implement any useful collection specialty, including new + Windows data sources, reports, visualizations, exports, or integrations, as + long as they obey the v4.0 collector contract. +- [x] Keep plugin output, logs, permissions, and failures separated from core + output while still including both in the final run manifest when selected. +- [x] Never silently execute arbitrary `.py` files merely because they exist in + `core/` or `plugins/`. +- [x] Ignore caches, tests, examples, private helpers, virtual environments, and + files beginning with `_` during collector discovery. +- [x] Require an explicit `plugin_id` and detect duplicate IDs before any collector + launches. +- [x] Decide and document whether an invalid core collector blocks the release/run + and whether an invalid plugin blocks only that plugin or the whole requested + plugin profile. The default policy should be: invalid core = launch refusal; + invalid explicitly selected plugin = launch refusal; invalid unselected + plugin = quarantine and report without executing it. + +### Strict collector file, class, and specialty standards + +Every executable collector must be a deliberately shaped Python module. A file +that fails any mandatory rule is not a collector and must not be launched. + +#### File and folder rules + +- [x] Require core collectors at `core//.py`. +- [x] Require plugins at `plugins/.py` or, for multi-file plugins, + `plugins//main.py` with all support files below that plugin's + directory. +- [x] Require lowercase `snake_case.py` filenames containing only letters, digits, + and underscores. +- [x] Require the filename to describe one specialty and one primary operation; + reject vague names such as `misc.py`, `utils.py`, `stuff.py`, or `main.py` + outside a plugin package. +- [x] Require one runnable collector per file. Shared helpers belong in a clearly + marked support module and may not be discovered as collectors. +- [x] Require UTF-8 source, valid Python syntax, and a module docstring explaining + the collector's purpose and data category. +- [x] Reject symlinks/junctions that resolve outside the project and reject plugin + support files that escape their plugin directory. + +#### Class and identity rules + +- [x] Require exactly one public collector class in each runnable module. +- [x] Require the class name to be the filename converted to PascalCase followed by + `Collector`; for example `network_info.py` must expose + `NetworkInfoCollector`. +- [x] Require core classes to inherit from `CoreCollector`. +- [x] Require plugin classes to inherit from `PluginCollector`. +- [x] Reject a module if the required class is missing, misspelled, duplicated, or + has a second public class that could be mistaken for the collector. +- [x] Require a stable, lowercase dotted ID matching its location and class, such as + `core.network.network_info` or `plugin.example_plugin`. +- [ ] Require class metadata constants or an immutable metadata object for: + `ID`, `NAME`, `VERSION`, `SPECIALTY`, `DESCRIPTION`, `AUTHOR`, and + `SUPPORTED_PLATFORMS`. +- [ ] Require plugin metadata to additionally declare requested capabilities, + privilege level, sensitive-data categories, network access, estimated cost, + timeout, maximum output, and minimum contract version. +- [ ] Require IDs, names, specialties, and versions to match their schemas and + reject duplicates or unsupported contract versions before launch. + +#### Specialty rules + +- [ ] Require `SPECIALTY` to be exactly one registered value, not an arbitrary free + text string. Initial values should cover system, hardware, process, memory, + filesystem, network, packet, wireless, Bluetooth, USB, browser, registry, + event-log, storage, encryption, media, SSH, diagnostics, reporting, and + integration. +- [ ] Require each collector to have one primary specialty. Related secondary + categories may be declared as metadata but may not change the collector's + identity. +- [ ] Require the specialty to match the declared artifact types and the collector's + documented responsibility. +- [ ] Reject a collector whose name, metadata, class, or outputs claim one specialty + while its implementation declares or performs an unrelated primary job. +- [ ] Require a separate collector when a feature has a different permission model, + timeout profile, sensitive-data classification, or output contract. + +#### Required class interface + +- [x] Require the following methods with exact names and type-checked signatures: + - [x] `metadata() -> CollectorMetadata`. + - [x] `validate(context: CollectorContext) -> ValidationResult`. + - [x] `collect(context: CollectorContext) -> CollectorResult`. + - [x] `cleanup(context: CollectorContext) -> None`. +- [ ] Permit optional `estimate(context) -> CollectionEstimate` and + `dependencies() -> tuple[str, ...]` methods only when they pass the contract + validator. +- [x] Require `collect()` to write through the artifact service and return artifact + references; direct package manipulation is forbidden. +- [x] Require collectors to report structured progress and never use undocumented + console output as their API. +- [ ] Require all expected failures to become typed result/error values rather than + process exits. +- [ ] Require docstrings for the class and every public method. +- [ ] Reject import-time collection, threads, subprocesses, network calls, file + writes, registry writes, prompts, or `sys.exit()` calls. + +#### Static and runtime preflight validation + +- [ ] Parse every discovered module with the Python AST before importing it. +- [ ] Validate path, filename, module docstring, imports, public names, class + inheritance, class name, metadata, method signatures, type annotations, and + forbidden top-level statements. +- [ ] Import each candidate in a short-lived validation subprocess with collection + disabled and with a restricted environment. +- [ ] Instantiate the class only after static validation succeeds. +- [ ] Validate metadata, dependencies, supported platform, requested capabilities, + output declarations, timeout, and size limits at runtime. +- [ ] Run a no-op contract probe against `validate()` and reject unexpected side + effects, hangs, exceptions, or malformed return values. +- [ ] Produce a preflight report listing valid, quarantined, and invalid collectors, + with exact file/line/rule failures. +- [ ] Refuse to launch before collection if a core collector or explicitly selected + plugin fails validation. +- [ ] Never “best effort” execute a malformed collector because another collector + succeeded. +- [ ] Cache validation only with a source hash, interpreter version, contract + version, and configuration hash; invalidate the cache whenever any changes. + +### Per-collector sandbox and failure isolation + +“Sandboxed” means every collector is isolated as an independently supervised unit; +it does not mean that a collector automatically has permission to access anything +on the machine. Access must be declared, approved, and enforced as far as the +Windows platform allows. + +- [ ] Launch each collector in its own worker process rather than importing and + running all collectors in the main process. +- [ ] Give each collector a private working directory: + `ACCESS/RUNS//collectors//`. +- [ ] Give each collector a private temporary directory, stdout/stderr capture, + structured event channel, and artifact staging area. +- [ ] Pass only a serializable `CollectorContext`/request payload into the worker; + do not share mutable engine objects across collectors. +- [ ] Enforce declared timeout, memory/output limits, file-count limits, and + cancellation through the supervisor. +- [ ] Terminate and clean up only the failed collector's process and workspace when + it crashes, hangs, exceeds limits, or is cancelled. +- [ ] Mark that collector `failed`/`cancelled` with its traceback stored in the + run manifest, while allowing independent collectors to finish. +- [ ] Prevent a collector from changing another collector's files, result state, + logger configuration, environment, or execution plan. +- [ ] Restrict artifact registration to paths inside the collector workspace and + copy/stream only approved artifacts into the run artifact store. +- [ ] Provide declared capability gates for filesystem reads, registry reads, + subprocesses, network capture, raw packet access, browser-data access, and + sensitive-file access. +- [ ] Require elevated privilege and explicit user approval for collectors that need + it; do not grant administrator access to every plugin by default. +- [ ] Capture subprocess trees so child processes do not survive a failed collector. +- [ ] Use OS/process isolation as the default boundary; treat Python-level import + restrictions as validation and defense-in-depth, not as a complete sandbox. +- [ ] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter + configuration, install packages, update the repository, or launch another + collector through the public context API. +- [ ] Provide a supervisor heartbeat and last-progress timestamp for every worker. +- [ ] Make the final package include the isolation result and failure reason for + every selected collector. + +### Replace the current architectural model + +- [ ] Replace global action/sub-action state with an immutable `RunRequest` that + contains the selected profile, explicit collector selections, concurrency, + output policy, cancellation token, and post-run action. +- [ ] Replace hard-coded filename lists and mode-specific branches with a typed + collector registry. Each collector declares its ID, category, supported + platforms, required privileges, estimated cost, dependencies, output types, + default profiles, and whether it is safe to parallelize. +- [ ] Replace scripts writing directly into the repository with a `RunContext` that + gives each collector a run ID, isolated workspace, logger, configuration + snapshot, artifact writer, and cancellation handle. +- [ ] Replace implicit current-working-directory behavior with explicit absolute + paths owned by the run context. +- [ ] Replace `print`, `exit`, and process-level shutdowns inside collectors with + typed results and typed errors returned to the orchestrator. +- [ ] Replace one giant execution-list algorithm with a planner that resolves + collector dependencies, removes duplicates, validates conflicts, and emits a + reproducible execution plan before any collection begins. +- [ ] Replace mode names that imply implementation details with user-facing + collection profiles. Preserve compatibility aliases for existing flags, but + make profiles the canonical internal concept. +- [ ] Replace a single shared mutable logger with run-scoped structured logging + that can still be rendered to the existing console/log-file formats. +- [ ] Replace ad hoc output filenames with an artifact catalog containing stable + artifact IDs, human-readable names, MIME/type information, byte counts, + timestamps, producer collector, and status. +- [ ] Replace “zip whatever happens to be in CODE” with packaging the run's + artifact catalog and manifest only. +- [ ] Replace cleanup based on deleting a discovered file list with ownership-based + cleanup that can remove only the current run's temporary workspace. + +### Define the v4.0 run model + +- [ ] Create a unique run directory before collection starts: + `ACCESS/RUNS//`. +- [ ] Store raw collector output under a controlled artifact tree rather than in + the source tree or project root. +- [ ] Create a run manifest at the beginning, update it as collectors start and + finish, and finalize it even after cancellation or partial failure. +- [ ] Record requested profile, resolved collector plan, Logicytics version, + configuration snapshot with secrets removed, host metadata, privilege state, + start/end times, cancellation state, errors, skipped items, and artifact + checksums. +- [ ] Give every collector an explicit lifecycle: validate, prepare, collect, + finalize, report result. +- [ ] Make collector results explicit: `succeeded`, `partial`, `skipped`, + `cancelled`, or `failed`. +- [ ] Allow partial runs to be packaged and clearly labeled instead of appearing + successful or being silently discarded. +- [ ] Make reruns selectable by collector ID and keep rerun artifacts separate from + the original run. +- [ ] Make the run plan deterministic: the same request and configuration produce + the same collector set and dependency order. + +### Separate responsibilities into layers + +- [ ] **CLI layer:** parse arguments, show help, request confirmation, and render + results; do not collect data or manipulate package files directly. +- [ ] **Application layer:** create the request, resolve the profile, plan the run, + coordinate collectors, and decide the final run status. +- [ ] **Collector layer:** perform one focused collection task through injected + services; do not import the main application or depend on global state. +- [ ] **Platform layer:** centralize PowerShell, WMIC/WMI, registry, filesystem, + process, network, and privilege access behind testable adapters. +- [ ] **Artifact layer:** validate paths, stream output, normalize metadata, compute + checksums, enforce size limits, and register artifacts. +- [ ] **Packaging layer:** consume the finalized artifact catalog and create ZIP, + hash, manifest, and summary outputs. +- [ ] **Maintenance layer:** keep update, debug, developer checks, and usage + analytics from being coupled to normal collection. + +### Make collection safe, bounded, and observable + +- [ ] Require an explicit consent/authorization acknowledgement before collection, + with a clear summary of the selected categories and sensitive outputs. +- [ ] Mark collectors that may copy credentials, private keys, personal files, + browser data, or packet contents as sensitive and make them opt-in for the + default profile. +- [ ] Add per-collector timeouts, retry policies, maximum output size, maximum file + size, maximum file count, and maximum total run size. +- [ ] Add cancellation checks inside long filesystem walks, packet capture, + memory collection, event-log queries, and copy loops. +- [ ] Prevent path traversal and symlink/junction escapes from leaving the run + workspace or copying unintended locations. +- [ ] Avoid loading entire command output, files, memory maps, or packet sets into + memory when streaming is possible. +- [ ] Redact secrets from logs and from diagnostic metadata even when the artifact + itself is intentionally collected. +- [ ] Never place credentials or raw sensitive evidence in console logs, exception + messages, temporary filenames, or update requests. +- [ ] Record resource usage and progress per collector: files scanned, files + copied, bytes written, packets observed, events processed, and elapsed time. +- [ ] Make failures actionable by recording the collector ID, operation, platform + error, remediation hint, and whether a retry is safe. + +### Make concurrency a planned capability + +- [ ] Use a scheduler with bounded workers instead of creating independent thread + pools inside collectors. +- [ ] Declare dependencies and resource classes so disk-heavy, network-heavy, + registry-sensitive, and interactive collectors can be coordinated. +- [ ] Prevent concurrent collectors from writing the same artifact path. +- [ ] Use thread-safe artifact registration and structured event reporting. +- [ ] Preserve deterministic manifest ordering even when collectors finish out of + order. +- [ ] Define which collectors may run in parallel and which must run serially. +- [ ] Provide a sequential mode for debugging and a bounded parallel mode for + normal use; do not make parallelism a hidden behavior of a particular flag. + +### Make configuration and profiles understandable + +- [ ] Replace scattered string lookups with a validated typed configuration object. +- [ ] Fail at plan-validation time for invalid values, rather than during a + collector's execution. +- [ ] Separate product settings, profile settings, collector settings, and runtime + overrides. +- [ ] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` + with documented collector membership. +- [ ] Allow explicit include/exclude collector selections to override a profile. +- [ ] Version the configuration schema and migrate older configuration files. +- [ ] Keep remote configuration optional, authenticated/validated, and out of the + critical local collection path. +- [ ] Never let a remote manifest silently add a collector to a user's run. + +### Improve the extension model + +- [ ] Define a stable `Collector` interface and version it. +- [ ] Require every MODS collector to declare metadata rather than being discovered + only by extension. +- [ ] Run mods in a sandboxed/isolated run workspace with declared inputs and + outputs. +- [ ] Give mods the same artifact writer, cancellation, timeout, logging, and + result contract as built-in collectors. +- [ ] Prevent a mod from changing global configuration, replacing built-in + collectors, or writing outside its permitted workspace without explicit + permission. +- [ ] Preserve compatibility with existing script mods through an adapter layer, + while making the typed collector API the v4.0 contract. + +### Improve packaging and evidence integrity + +- [ ] Package only finalized artifacts referenced by the manifest. +- [ ] Stream ZIP creation and hash calculation to support large runs. +- [ ] Add per-artifact SHA-256 values and a package-level SHA-256 value. +- [ ] Include a machine-readable manifest and a human-readable summary report. +- [ ] Include collector status, skipped/failed reasons, and collection timestamps + in the summary. +- [ ] Preserve artifact provenance: collector ID, source category, collection time, + and transformation steps. +- [ ] Verify package contents against the manifest before marking the run complete. +- [ ] Use atomic writes and temporary package names so interrupted packaging cannot + look like a finished result. +- [ ] Keep raw evidence, derived reports, logs, hashes, and metadata in separate + package sections. + +### Improve the public API and testability + +- [ ] Make the package importable without starting a collection, opening files, or + requiring Windows-only APIs at import time. +- [ ] Replace singleton-heavy APIs with dependency injection where state matters. +- [ ] Keep a small stable public API: configuration, planning, running, querying + run status, and reading artifacts. +- [ ] Keep platform adapters mockable so collectors can be tested on non-Windows + development machines. +- [ ] Add contract tests for every collector's metadata, lifecycle, result states, + artifact declarations, and error behavior. +- [ ] Add planner tests for profiles, explicit selections, dependencies, conflicts, + duplicate collectors, and invalid configurations. +- [ ] Add run tests for sequential, bounded-parallel, cancellation, timeout, + partial-failure, rerun, and cleanup behavior. +- [ ] Add packaging tests proving that unregistered files cannot enter a package + and that manifest hashes match the final bytes. +- [ ] Add golden-output tests for stable text, CSV, HTML, graph, and manifest + formats, allowing platform-specific values to be normalized. +- [ ] Add end-to-end Windows tests for permissions, PowerShell, registry, WMI, + event logs, BitLocker, networking, and Sysinternals where available. + +### Migration rules from the old design + +- [ ] Keep existing command-line flags as compatibility aliases during migration. +- [ ] Map every legacy flag to a named v4.0 profile or explicit collector set. +- [ ] Wrap legacy scripts in compatibility collectors before rewriting their data + gathering logic. +- [ ] Move one collector at a time from direct filesystem writes to artifact APIs. +- [ ] Keep legacy output names as aliases or compatibility copies only where users + rely on them; make the run artifact tree canonical. +- [ ] Remove global mutable state after all collectors use `RunContext`. +- [ ] Remove direct `exit()` calls, current-directory assumptions, nested worker + pools, and package-wide cleanup from collectors. +- [ ] Remove compatibility shims only after the v4.0 API and migration documentation + cover their replacement. + +## v4.0 goals + +- [ ] Recreate Logicytics as a Windows system-data collection and packaging tool. +- [ ] Preserve every supported collection category, execution mode, output type, + configuration option, and extension point listed below. +- [ ] Keep collection modules independently runnable as well as runnable through + the main orchestrator. +- [ ] Make collection results predictable, timestamped, inspectable, and easy to + verify after export. +- [ ] Keep administrator checks, permission-aware collection, cancellation, and + failure reporting as first-class behavior. +- [ ] Keep the default run reasonably quick while retaining a deeper, slower mode + for exhaustive collection. +- [ ] Keep the project Windows-focused, including PowerShell, registry, WMI, + WMIC, Windows event logs, BitLocker, and Sysinternals integrations. + +## 1. Main engine and execution lifecycle + +- [ ] Provide a single main entry point that: + - [ ] Parses command-line flags and validates their combinations. + - [ ] Handles special actions before collection starts. + - [ ] Checks privileges and relevant Windows prerequisites. + - [ ] Extracts Sysinternals tools when permitted by configuration. + - [ ] Builds the execution list for the selected mode. + - [ ] Runs the selected Python, PowerShell, executable, and batch collectors. + - [ ] Captures collector output and converts script messages into Logicytics log + levels where applicable. + - [ ] Continues collecting when an individual collector fails, while recording + the failure. + - [ ] Packages generated evidence after collection. + - [ ] Produces a SHA-256 hash beside every package. + - [ ] Performs the requested shutdown or reboot action at the end. + - [ ] Handles Ctrl+C gracefully and attempts final packaging/cleanup. + - [ ] Offers a final exit prompt for interactive use. +- [ ] Discover runnable files recursively from the CODE directory. +- [ ] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. +- [ ] Exclude engine/library directories and underscore-prefixed helper files from + automatic collector discovery. +- [ ] Support a configurable worker limit for parallel collection. +- [ ] Provide per-collector success, failure, and duration information. + +### Execution modes + +- [x] `--default`: run the standard set of collectors sequentially. +- [x] `--threaded`: run the standard set concurrently. +- [x] `--minimal`: run the quick/basic collection set. +- [x] `--depth`: run the standard set concurrently plus the slow, large-data + collectors. +- [ ] `--nopy`: run the non-Python collectors for systems without Python. +- [ ] `--modded`: run the normal collection set plus all supported files in MODS. +- [x] `--performance-check`: run collectors sequentially, measure each duration, + and write a performance summary table. +- [ ] Show help when no collection action is selected. +- [ ] Reject multiple mutually exclusive collection actions. + +### Post-run actions + +- [ ] `--reboot`: schedule a system reboot after packaging. +- [ ] `--shutdown`: schedule a system shutdown after packaging. +- [ ] Ensure post-run actions do not run prematurely during performance analysis. + +## 2. Command-line and interaction features + +- [ ] Provide descriptive help text for every flag. +- [ ] Support the current action flags: `default`, `threaded`, `modded`, `depth`, + `nopy`, `minimal`, `performance-check`, and `usage`. +- [ ] Support side actions: `debug`, `update`, and `dev`. +- [ ] Provide action/sub-action exclusivity validation and clear invalid-combination + errors. +- [ ] Provide colored console output for status, warnings, errors, and prompts. +- [ ] Provide a semantic flag-matching feature that can map natural-language user + input to the closest command-line flag. +- [ ] Match input against both flag names and their descriptions. +- [ ] Use a configurable similarity threshold. +- [ ] Fall back to historical flag suggestions when direct matching is weak. +- [ ] Persist optional compressed interaction history locally only when enabled. +- [ ] Record matched flag, input, accuracy, timestamp, and device name in history. +- [ ] Track per-flag usage counts. +- [ ] Provide `--usage` statistics including total interactions, average accuracy, + common device/input values, and per-flag frequency. +- [ ] Generate a flag-usage bar graph in the ACCESS/DATA area. +- [ ] Allow model debug/progress output to be enabled independently from normal + Logicytics debug logging. + +## 3. Core Logicytics engine/library API + +### Command execution + +- [x] Expose a reusable command runner that returns command stdout. +- [ ] Execute Python collectors through the configured Python runtime. +- [ ] Execute PowerShell collectors after unblocking them when necessary. +- [ ] Execute other supported script types through PowerShell where applicable. +- [x] Parse `LEVEL: message` output from non-Python scripts into structured logs. + +### File discovery + +- [x] Provide recursive file listing with extension filters. +- [x] Support appending to an existing file list. +- [x] Support excluded files, extensions, and directories. +- [x] Normalize relative paths for execution and integrity comparisons. + +### Environment checks + +- [x] Check whether the process has administrator privileges. +- [x] Check the PowerShell execution policy. +- [x] Check whether UAC is enabled. +- [x] Detect and extract the bundled Sysinternals archive unless a local ignore + marker opts out. +- [x] Report missing, archived, or extracted Sysinternals tools without stopping + unrelated collection. + +### Configuration + +- [ ] Load `config.ini` from the project CODE directory. +- [ ] Expose debug level, version, current file manifest, log retention, worker + count, preference persistence, and all collector settings. +- [ ] Support remote configuration retrieval for update/integrity checks. +- [ ] Keep configuration parsing available to every collector through the package. + +### Logging + +- [ ] Provide a singleton logger shared by the engine and collectors. +- [ ] Log to console with configurable colors and levels. +- [ ] Log to ACCESS/LOGS/Logicytics.log. +- [ ] Support DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, and EXCEPTION levels. +- [ ] Include timestamps and structured, readable log rows. +- [ ] Support log truncation controls and optional deletion of the previous log. +- [ ] Provide raw logging, newline separators, typed message dispatch, and batched + execution-message parsing. +- [x] Provide a function decorator that records function execution and timing. +- [x] Provide an exception helper that logs and raises a requested exception type. +- [x] Provide a deprecation decorator with removal version, reason, and optional + stack trace. +- [ ] Preserve a dedicated DEBUG log and diagnostic context for `--debug`. + +### Output and packaging + +- [ ] Create ACCESS/LOGS, ACCESS/LOGS/DEBUG, ACCESS/LOGS/PERFORMANCE, + ACCESS/DATA/Zip, and ACCESS/DATA/Hashes automatically. +- [ ] Collect generated files without including source code, executables, model + files, configuration secrets, caches, or library internals in the evidence + package. +- [ ] Include generated files from CODE in the package. +- [ ] Include MODS output in a separate MODS package during modded runs. +- [ ] Preserve nested directory structure inside packages. +- [ ] Name packages using the action and timestamp. +- [ ] Generate a SHA-256 digest for each package and move both artifacts into the + ACCESS/DATA output directories. +- [ ] Provide a file-opening helper for viewing generated artifacts. +- [ ] Implement the v4.0 temporary-workspace lifecycle: collectors write into a + run-specific temporary directory, the packager consumes that directory, and + cleanup occurs after successful or interrupted runs. + +## 4. Standard system-data collectors + +Every collector below must remain independently runnable and available to the +appropriate default/deep/non-Python mode. + +### System, hardware, and operating-system inventory + +- [x] Run `systeminfo` and save the complete output. +- [x] Query BIOS manufacturer, name, and version through modern Windows CIM data and + provide an HTML table representation. +- [x] Query operating-system caption, service-pack information, and related details + through modern Windows CIM data. +- [x] Query computer model, manufacturer, and processor count. +- [x] Query disk-drive model and size. +- [x] Collect Windows build, physical/virtual memory, current user, SID, language + IDs, computer name, date/time, and system drive. +- [x] Enumerate Windows optional features and their enabled/disabled state. +- [x] Collect local network adapter configuration through a bounded `ipconfig /all` + report; structured adapter fields remain a later enhancement. +- [x] Collect installed/visible device-driver information with `driverquery /v`. +- [x] Collect group-policy result information with `GPResult /r`. +- [x] Export the running process/task list as CSV, including verbose details. +- [x] Generate a recursive file-system tree for the system drive with explicit bounds. +- [x] Generate a recursive directory listing of the system drive using the deep, + threaded collector with explicit bounds. + +### Network inventory and activity + +- [x] Collect per-interface network I/O counters: bytes, packets, errors, and + dropped traffic. +- [x] Collect active network connections, endpoints, protocol types, and status. +- [x] Collect network interface addresses, netmasks, and broadcast addresses. +- [x] Collect interface speed, duplex, and link-up state. +- [x] Save bounded `ipconfig /all` output. +- [x] Associate active connections with process names and PIDs. +- [x] Resolve and save the hostname and local IP addresses without probing remote hosts. +- [x] Measure average and peak upload/download bandwidth over configurable sample + counts and intervals without generating network traffic. +- [x] Correct the Wi-Fi/WiFi interface-name variation automatically. +- [x] Sniff a configurable number of packets on a configurable interface with a + timeout and retry window. +- [x] Record source/destination IP, protocol, source/destination ports, and packet + metadata for IP/TCP/UDP/ICMP traffic. +- [x] Export packet observations to CSV. +- [x] Build and save a source/destination network graph with protocol edge labels. +- [ ] Clean up packet-sniffer graph state and plotting resources. + +### Wireless, Bluetooth, and removable-device history + +- [x] Enumerate saved Wi-Fi profiles through `netsh` without requesting key material. +- [x] Retrieve each saved profile's key content when available. +- [x] Save profile names and associated credentials to a report. +- [x] Query paired/available Bluetooth devices through PowerShell PnP data. +- [x] Save Bluetooth friendly name/instance ID, class, status, problem code, and + presence state; manufacturer/description enrichment remains a later addition. +- [x] Enumerate paired Bluetooth devices with names and extracted address-like PnP identifiers. +- [x] Append timestamped Bluetooth collection sections to a historical report. +- [x] Enumerate USB storage device class, instance, friendly name, and last-write + timestamps from the Windows registry. +- [x] Record USB device class, device ID, friendly name, and registry last-write + time. + +### Windows logs, registry, storage, and encryption + +- [x] Export a bounded sample of up to 1,000 Windows System event-log entries to CSV; + full log backup remains a later configurable deep collection. +- [x] Read Security, Application, and System event logs through the modern Windows event API. +- [x] Record event category, generated time, source, event ID, type, and message data + in separate bounded CSV reports. +- [ ] Run the event-log parsers concurrently and write separate reports. +- [x] Export the HKLM registry hive to a `.reg` backup. +- [x] Enumerate logical volumes with drive type, filesystem, free space, size, and + volume name. +- [x] Enumerate mounted volume GUIDs with `mountvol`. +- [x] Report BitLocker status for detected drive letters using `manage-bde`. +- [x] Report `Get-BitLockerVolume` output when PowerShell is available. +- [x] Include collection timestamp, user, admin state, hostname, and platform in + the encrypted-volume report. + +### Memory and process diagnostics + +- [x] Capture RAM and page-file totals, used/available values, and utilization percent. +- [x] Capture architecture, OS, machine, processor, page size, CPU count/frequency, + and boot time. +- [x] Create a process memory-map report containing readable regions, addresses, + sizes, RSS, permissions, mapped path, and region index. +- [x] Enforce an optional output-size limit and disk-space safety margin. +- [x] Record truncation when the configured memory-report limit is exceeded. +- [x] Store memory diagnostics in a configurable dump directory. + +### User files and media + +- [x] Search the system drive for files whose names contain sensitive-data keywords + such as password, secret, code, login, API, key, token, auth, credentials, + private, certificate, SSH, PGP, or wallet. +- [x] Restrict sensitive-file mining to the supported document, data, archive, + database, configuration, log, and text extensions. +- [x] Copy matching files into a dedicated report directory. +- [x] Use concurrent search/copy operations and skip files over the configured + per-file size ceiling. +- [x] Back up the current user's Pictures and Videos directories. +- [x] Recursively collect supported image/video formats, including JPG, JPEG, PNG, + and MP4. +- [x] Copy media with timestamped destination names while preserving file metadata. +- [x] Back up the current user's `.ssh` directory, including keys and configuration, + into a dedicated SSH backup directory. +- [x] Copy browser-related data for Edge, Chrome, Firefox, Opera, and Opera GX. +- [x] Copy the configured Windows security, group-policy, and event-log data paths + into labeled browser/system-data directories. + +### Sysinternals integration + +- [x] Support the bundled Sysinternals Suite archive and optional ignore marker. +- [x] Run available `psfile`, `PsGetsid`, `PsInfo`, `pslist`, `PsLoggedon`, and + `psloglist` tools. +- [x] Append each tool's stdout and relevant stderr to a consolidated report. +- [x] Report absent binaries, archived-only state, extracted state, and execution + errors. + +## 5. Maintenance and developer features + +### Debugger (`--debug`) + +- [ ] Fetch the remote configuration manifest. +- [ ] Compare local and remote versions, including snapshot-version handling. +- [ ] Compare the local file tree to the configured required-file manifest. +- [ ] Report missing and extra files. +- [ ] Check Sysinternals archive/binary state. +- [x] Report admin/UAC state and PowerShell execution policy. +- [x] Report Python, psutil, executable, prefix, virtual-environment, CPU, and + logging context. +- [ ] Report whether the running Python version is recommended, supported, or + incompatible. +- [x] Write diagnostic output to a dedicated debug log. + +### Update action (`--update`) + +- [x] Verify that Git is available. +- [x] Verify that the project is a Git checkout. +- [x] Pull the configured upstream repository when the user explicitly requests it. +- [x] Report update success, failure, and repository-state errors. +- [ ] Support launching a selected action in a new command window when required by + the update/special-action workflow. + +### Developer action (`--dev`) + +- [ ] Present contribution and repository-organization checks interactively. +- [ ] Check naming conventions, CODE placement, documentation/docstrings, and + one-main-feature-per-file organization. +- [ ] Compare current files against the configured manifest. +- [ ] Display added, removed, and unchanged files with colored status markers. +- [ ] Update the manifest in `config.ini` after confirmation. +- [ ] Prompt for and validate the next semantic version. +- [ ] Keep excluded history/cache/Sysinternals files out of manifest comparisons. + +## 6. MODS extension system + +- [ ] Treat MODS as an opt-in extension directory. +- [ ] Discover `.py`, `.exe`, `.ps1`, and `.bat` mods. +- [ ] Run mods after the normal Logicytics collectors. +- [ ] Allow users to add custom collectors without changing the engine. +- [ ] Include mod-generated data in a separately named package. +- [ ] Document the mod contract, supported file types, execution order, output + location, logging convention, and failure behavior. +- [ ] Keep underscore-prefixed helper files out of automatic mod execution. + +## 7. Configuration and output contract + +- [ ] Recreate general settings for debug logging, old-log deletion, worker count, + and preference-history persistence. +- [ ] Recreate flag-matching settings for model name, minimum accuracy, and model + debug output. +- [ ] Recreate memory-dump settings for file-size limit, safety margin, and dump + directory. +- [ ] Recreate network sampling settings for count and interval. +- [ ] Recreate packet-sniffer settings for interface, packet count, timeout, and + retry duration. +- [x] Version the configuration schema and provide migration/default handling. +- [ ] Define stable output names and formats for every collector. +- [ ] Keep logs, hashes, packages, graphs, CSVs, HTML, text, and copied evidence + discoverable under one run-specific output tree. +- [ ] Include collection metadata: version, action, start/end times, host, user, + privilege state, enabled collectors, skipped collectors, and failures. + +## 8. v4.0 delivery phases + +### Phase 1 — Foundation + +- [ ] Define the v4.0 package layout and run manifest format. +- [ ] Implement configuration loading, structured logging, file discovery, command + execution, privilege checks, cancellation, and error aggregation. +- [ ] Implement the temporary run directory and cleanup lifecycle. +- [ ] Rename ACCESS folder to output/ and LOGS/ to logs/, PACKAGES/ to data/ (and remove RUNS/ as it should be the same as data/) + +### Phase 2 — Engine and packaging + +- [ ] Implement CLI parsing, mode selection, execution scheduling, worker limits, + performance reporting, and post-run actions. +- [ ] Implement ZIP creation, SHA-256 hashing, package naming, metadata, and output + relocation. +- [ ] Implement the MODS discovery and packaging contract. + +### Phase 3 — Core collectors + +- [ ] Implement system/hardware inventory and command-output collectors. +- [ ] Implement process, memory, filesystem, network, and adapter collectors. +- [ ] Implement registry, event-log, storage, encryption, Bluetooth, USB, Wi-Fi, + SSH, browser, and media collectors. + +### Phase 4 — Deep collection and visualization + +- [ ] Implement sensitive-file mining, recursive directory/tree collection, and + Sysinternals integration. +- [ ] Implement packet capture, CSV export, and network graph generation. +- [ ] Implement flag-history statistics and usage graph generation. + +### Phase 5 — Maintenance and release readiness + +- [ ] Implement debugger, update, developer checks, manifest comparison, and + version handling. +- [ ] Add per-collector tests using mocked Windows command/registry/WMI responses. +- [ ] Add Windows integration tests for each output contract. +- [ ] Test default, threaded, minimal, deep, non-Python, performance, modded, + debug, update, usage, shutdown, reboot, cancellation, and permission-denied + flows. +- [ ] Verify that every collector is represented in the CLI mode matrix and the + v4.0 documentation. +- [ ] Verify that packages contain generated evidence and metadata only. +- [ ] Verify that hashes reproduce from the final package bytes. +- [ ] Update README, contribution guidance, configuration documentation, and the + repository wiki for v4.0. + +## 9. v4.0 completion checklist + +- [ ] Every feature in this file has an implementation owner/status. +- [ ] Every collector can be run directly and through the orchestrator. +- [ ] Every mode has an explicit collector inclusion matrix. +- [ ] Every output has a documented path, format, and retention rule. +- [ ] A failed collector cannot silently make the run appear successful. +- [ ] A cancelled run leaves a recoverable partial-run report and cleans only its + own temporary data. +- [ ] A normal run creates a package, a matching SHA-256 hash, and a run manifest. +- [ ] Debug/update/developer tools are separated from normal collection behavior. +- [ ] The final v4.0 release is documented as a complete recreation of the feature + surface listed here. + +Once all is complete, ask the user to git clone the wiki of logicytics so that we may update the DOCS completly From da3e69447984394bd1738960883ab58021ffa8f3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:45:56 +0300 Subject: [PATCH 093/316] docs: record verified v4 contract work --- TODO.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index f1ce6727..2a99122e 100644 --- a/TODO.md +++ b/TODO.md @@ -268,7 +268,7 @@ that fails any mandatory rule is not a collector and must not be launched. - [x] `validate(context: CollectorContext) -> ValidationResult`. - [x] `collect(context: CollectorContext) -> CollectorResult`. - [x] `cleanup(context: CollectorContext) -> None`. -- [ ] Permit optional `estimate(context) -> CollectionEstimate` and +- [x] Permit optional `estimate(context) -> CollectionEstimate` and `dependencies() -> tuple[str, ...]` methods only when they pass the contract validator. - [x] Require `collect()` to write through the artifact service and return artifact @@ -277,28 +277,28 @@ that fails any mandatory rule is not a collector and must not be launched. console output as their API. - [ ] Require all expected failures to become typed result/error values rather than process exits. -- [ ] Require docstrings for the class and every public method. +- [x] Require docstrings for the class and every public method. - [ ] Reject import-time collection, threads, subprocesses, network calls, file writes, registry writes, prompts, or `sys.exit()` calls. #### Static and runtime preflight validation -- [ ] Parse every discovered module with the Python AST before importing it. +- [x] Parse every discovered module with the Python AST before importing it. - [ ] Validate path, filename, module docstring, imports, public names, class inheritance, class name, metadata, method signatures, type annotations, and forbidden top-level statements. - [ ] Import each candidate in a short-lived validation subprocess with collection disabled and with a restricted environment. -- [ ] Instantiate the class only after static validation succeeds. +- [x] Instantiate the class only after static validation succeeds. - [ ] Validate metadata, dependencies, supported platform, requested capabilities, output declarations, timeout, and size limits at runtime. - [ ] Run a no-op contract probe against `validate()` and reject unexpected side effects, hangs, exceptions, or malformed return values. - [ ] Produce a preflight report listing valid, quarantined, and invalid collectors, with exact file/line/rule failures. -- [ ] Refuse to launch before collection if a core collector or explicitly selected +- [x] Refuse to launch before collection if a core collector or explicitly selected plugin fails validation. -- [ ] Never “best effort” execute a malformed collector because another collector +- [x] Never “best effort” execute a malformed collector because another collector succeeded. - [ ] Cache validation only with a source hash, interpreter version, contract version, and configuration hash; invalidate the cache whenever any changes. @@ -310,7 +310,7 @@ it does not mean that a collector automatically has permission to access anythin on the machine. Access must be declared, approved, and enforced as far as the Windows platform allows. -- [ ] Launch each collector in its own worker process rather than importing and +- [x] Launch each collector in its own worker process rather than importing and running all collectors in the main process. - [ ] Give each collector a private working directory: `ACCESS/RUNS//collectors//`. From c75822bf9abc8bf38a5030525a1dff8981428db6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:47:11 +0300 Subject: [PATCH 094/316] feat: provide private collector temp directories --- logicytics/contracts.py | 2 ++ logicytics/runtime.py | 3 +++ logicytics/validation_worker.py | 3 +++ tests/test_running_processes_collector.py | 1 + 4 files changed, 9 insertions(+) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 471f7789..974be729 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -214,6 +214,7 @@ def __init__( run_id: str, collector_id: str, workspace: Path, + temporary_directory: Path, artifacts: "ArtifactWriter", logger: EventLogger, settings: Mapping[str, Any], @@ -222,6 +223,7 @@ def __init__( self.run_id = run_id self.collector_id = collector_id self.workspace = workspace + self.temporary_directory = temporary_directory self.artifacts = artifacts self.logger = logger self.settings = settings diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 7328e98c..bc30c43b 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -82,6 +82,8 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu """Run a single collector in an isolated child process.""" workspace = Path(str(payload["workspace"])) workspace.mkdir(parents=True, exist_ok=True) + temporary_directory = workspace / "tmp" + temporary_directory.mkdir(exist_ok=True) stdout_path = workspace / "stdout.log" stderr_path = workspace / "stderr.log" try: @@ -99,6 +101,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu run_id=str(payload["run_id"]), collector_id=metadata.id, workspace=workspace, + temporary_directory=temporary_directory, artifacts=writer, logger=FileEventLogger( workspace / "events.jsonl", diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index a01d3e05..99e5d425 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -79,10 +79,13 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> collector = collector_type() with tempfile.TemporaryDirectory(prefix="logicytics-validation-") as temporary: workspace = Path(temporary) + temporary_directory = workspace / "tmp" + temporary_directory.mkdir() context = CollectorContext( run_id="validation", collector_id=metadata.id, workspace=workspace, + temporary_directory=temporary_directory, artifacts=_ProbeArtifactWriter(), logger=_ProbeLogger(), settings={}, diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index a0f411e0..91bcacbd 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -47,6 +47,7 @@ def test_access_denied_is_skipped_not_failed(self) -> None: run_id="test-run", collector_id="core.process.running_processes", workspace=workspace, + temporary_directory=workspace / "tmp", artifacts=_UnusedArtifactWriter(), logger=_NoopLogger(), settings={}, From a9e710d1d2c6338e10c33259cbdfd58a04d1c258 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:56:35 +0300 Subject: [PATCH 095/316] feat: reject collector import-time calls --- logicytics/discovery.py | 77 +++++++++++++++++++++++++++----- tests/test_core_functionality.py | 27 +++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index ad142f7e..e3fcfc12 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -15,9 +15,6 @@ _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} -_FORBIDDEN_TOP_LEVEL_CALLS = {"exit", "quit", "input", "open", "print"} - - @dataclass(slots=True) class CollectorCandidate: """A file that may be a runnable collector.""" @@ -89,6 +86,72 @@ def _top_level_call_name(node: ast.Call) -> str | None: return None +class _ImportTimeCallFinder(ast.NodeVisitor): + """Find calls whose expressions execute while Python imports a module.""" + + def __init__(self) -> None: + self.calls: list[ast.Call] = [] + + def visit_Call(self, node: ast.Call) -> None: + """Record a call and avoid duplicate nested call diagnostics.""" + self.calls.append(node) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """Inspect decorators, defaults, and annotations but not a function body.""" + self._visit_callable_header(node) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """Inspect async callable headers without treating deferred bodies as imports.""" + self._visit_callable_header(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + """Inspect lambda defaults without treating its deferred body as import-time work.""" + self._visit_arguments(node.args) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + """Inspect class headers and class-body expressions evaluated at definition time.""" + for decorator in node.decorator_list: + self.visit(decorator) + for base in node.bases: + self.visit(base) + for keyword in node.keywords: + self.visit(keyword.value) + for item in node.body: + if isinstance(item, ast.Expr) and isinstance(item.value, ast.Constant): + continue + self.visit(item) + + def _visit_callable_header(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + for decorator in node.decorator_list: + self.visit(decorator) + self._visit_arguments(node.args) + if node.returns is not None: + self.visit(node.returns) + + def _visit_arguments(self, arguments: ast.arguments) -> None: + for default in (*arguments.defaults, *arguments.kw_defaults): + if default is not None: + self.visit(default) + for argument in (*arguments.posonlyargs, *arguments.args, *arguments.kwonlyargs): + if argument.annotation is not None: + self.visit(argument.annotation) + if arguments.vararg is not None and arguments.vararg.annotation is not None: + self.visit(arguments.vararg.annotation) + if arguments.kwarg is not None and arguments.kwarg.annotation is not None: + self.visit(arguments.kwarg.annotation) + + +def _validate_import_time_expressions(tree: ast.Module, candidate: CollectorCandidate) -> None: + """Reject calls that can collect, mutate, or exit before worker isolation exists.""" + finder = _ImportTimeCallFinder() + finder.visit(tree) + for call in finder.calls: + name = _top_level_call_name(call) or ast.unparse(call.func) + candidate.static_errors.append( + f"forbidden import-time call: {name} (line {call.lineno})" + ) + + def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: expected_class = _pascal_case(path.name) candidate = CollectorCandidate(path=path, kind=kind, expected_class=expected_class) @@ -122,20 +185,14 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: elif public_classes: _validate_class_shape(public_classes[0], candidate) + _validate_import_time_expressions(tree, candidate) for item in tree.body: if isinstance(item, ast.Expr) and isinstance(item.value, ast.Constant): continue if isinstance(item, (ast.Assign, ast.AnnAssign)): - if any(isinstance(node, ast.Call) for node in ast.walk(item.value)): - candidate.static_errors.append("forbidden call in top-level assignment") continue if isinstance(item, (ast.Import, ast.ImportFrom, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)): continue - if isinstance(item, ast.Expr) and isinstance(item.value, ast.Call): - call_name = _top_level_call_name(item.value) - if call_name in _FORBIDDEN_TOP_LEVEL_CALLS or call_name: - candidate.static_errors.append(f"forbidden top-level call: {call_name or 'unknown'}") - continue candidate.static_errors.append(f"forbidden top-level statement: {type(item).__name__}") return candidate diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 8ad52941..7b2a3416 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -332,6 +332,33 @@ def test_preflight_rejects_collector_print_calls(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("collectors must not print; use structured progress or logging", report.invalid[0].static_errors) + def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> None: + """Collection-like work must not run before a worker has isolated the collector.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + "class SystemInfoCollector(CoreCollector):", + "class SystemInfoCollector(CoreCollector, type(open('unexpected.txt'))):", + ).replace( + " @classmethod\n def metadata", + " marker = input('unexpected prompt')\n\n @classmethod\n def metadata", + ).replace( + "def validate(self, context: CollectorContext) -> ValidationResult:", + "def validate(self, context: CollectorContext = open('another.txt')) -> ValidationResult:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + errors = report.invalid[0].static_errors + self.assertTrue(any("forbidden import-time call: type" in error for error in errors)) + self.assertTrue(any("forbidden import-time call: input" in error for error in errors)) + self.assertTrue(any("forbidden import-time call: open" in error for error in errors)) + def test_capability_gate_requires_explicit_approval(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From fd40539791874ea261dc3ec763bb2de2f137c8b1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:57:57 +0300 Subject: [PATCH 096/316] feat: limit collector artifact counts --- logicytics/artifacts.py | 4 ++++ logicytics/contracts.py | 1 + logicytics/runtime.py | 1 + tests/test_core_functionality.py | 19 ++++++++++++++++++- 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index f32f9ae9..ff854007 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -37,11 +37,13 @@ def __init__( workspace: Path, artifact_root: Path, maximum_output_bytes: int, + maximum_artifact_files: int, ) -> None: self._collector_id = collector_id self._workspace = workspace.resolve() self._artifact_root = artifact_root.resolve() self._maximum_output_bytes = maximum_output_bytes + self._maximum_artifact_files = maximum_artifact_files self._bytes_registered = 0 self._artifacts: list[Artifact] = [] @@ -57,6 +59,8 @@ def register_file(self, source: Path, *, media_type: str = "application/octet-st size_bytes = source.stat().st_size if self._bytes_registered + size_bytes > self._maximum_output_bytes: raise ArtifactError("collector output exceeds its declared maximum_output_bytes") + if len(self._artifacts) >= self._maximum_artifact_files: + raise ArtifactError("collector artifact count exceeds its declared maximum_artifact_files") relative_source = source.relative_to(self._workspace) safe_collector_id = self._collector_id.replace(".", "_") diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 974be729..118b733f 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -97,6 +97,7 @@ class CollectorMetadata: default_profiles: tuple[str, ...] = ("standard",) timeout_seconds: int = 60 maximum_output_bytes: int = 100 * 1024 * 1024 + maximum_artifact_files: int = 500 minimum_contract_version: str = CONTRACT_VERSION parallel_safe: bool = True diff --git a/logicytics/runtime.py b/logicytics/runtime.py index bc30c43b..866667d7 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -96,6 +96,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu workspace, Path(str(payload["artifact_root"])), metadata.maximum_output_bytes, + metadata.maximum_artifact_files, ) context = CollectorContext( run_id=str(payload["run_id"]), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 7b2a3416..8b06773d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -168,10 +168,27 @@ def test_artifacts_cannot_escape_collector_workspace(self) -> None: artifact_root.mkdir() outside = root / "outside.txt" outside.write_text("no", encoding="utf-8") - writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024) + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) with self.assertRaises(ArtifactError): writer.register_file(outside) + def test_artifact_registration_enforces_collector_file_count_limit(self) -> None: + """A collector cannot exceed its declared artifact count even with tiny files.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + first = workspace / "first.txt" + second = workspace / "second.txt" + first.write_text("one", encoding="utf-8") + second.write_text("two", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) + writer.register_file(first, media_type="text/plain") + with self.assertRaisesRegex(ArtifactError, "maximum_artifact_files"): + writer.register_file(second, media_type="text/plain") + def test_preflight_plan_run_and_package(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 9bfeef626571fb1c8912b0e75a7d40ca7d2c485c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:59:20 +0300 Subject: [PATCH 097/316] feat: record collector heartbeats --- logicytics/manifest.py | 1 + logicytics/runtime.py | 45 ++++++++++++++++++++++++++------ tests/test_core_functionality.py | 2 ++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/logicytics/manifest.py b/logicytics/manifest.py index f1ad83ab..56597944 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -34,6 +34,7 @@ class CollectorRecord: duration_seconds: float | None = None event_count: int = 0 last_progress_at: str | None = None + heartbeat_at: str | None = None def apply_result(self, result: CollectorResult, duration_seconds: float | None = None) -> None: """Copy a worker result into this serializable record.""" diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 866667d7..765d52bc 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -4,6 +4,7 @@ import contextlib import importlib.util +import json import multiprocessing import os import queue @@ -45,6 +46,8 @@ class _ActiveWorker: started_at: float timeout_seconds: int workspace: Path + last_event_count: int = 0 + last_heartbeat_at: float = 0.0 def _load_collector(path: Path, expected_class: str): @@ -262,6 +265,7 @@ def _supervise( ) records[candidate.metadata.id].status = "running" records[candidate.metadata.id].started_at = utc_now() + records[candidate.metadata.id].heartbeat_at = utc_now() process.start() active[candidate.metadata.id] = _ActiveWorker( candidate.metadata.id, @@ -287,6 +291,8 @@ def _supervise( write_manifest(manifest_path, manifest) for collector_id, worker in tuple(active.items()): + if self._refresh_worker_progress(records[collector_id], worker): + write_manifest(manifest_path, manifest) if monotonic() - worker.started_at > worker.timeout_seconds: self._terminate_process_tree(worker.process) active.pop(collector_id) @@ -324,17 +330,40 @@ def _terminate_process_tree(process: multiprocessing.Process) -> None: process.join(timeout=2) @staticmethod - def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) -> None: - """Attach result, elapsed time, and progress accounting to one record.""" - record.apply_result(result, duration_seconds=round(monotonic() - worker.started_at, 3)) + def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: + """Persist worker liveness and any newly written structured progress events.""" + changed = False + now = monotonic() + if now - worker.last_heartbeat_at >= 1: + record.heartbeat_at = utc_now() + worker.last_heartbeat_at = now + changed = True events_path = worker.workspace / "events.jsonl" - if events_path.exists(): + if not events_path.exists(): + return changed + try: events = events_path.read_text(encoding="utf-8").splitlines() - record.event_count = len(events) - if events: - import json + except OSError: + return changed + if len(events) <= worker.last_event_count: + return changed + worker.last_event_count = len(events) + record.event_count = len(events) + try: + event = json.loads(events[-1]) + except json.JSONDecodeError: + return changed + timestamp = event.get("at") + if isinstance(timestamp, str): + record.last_progress_at = timestamp + changed = True + return changed - record.last_progress_at = json.loads(events[-1]).get("at") + @staticmethod + def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) -> None: + """Attach result, elapsed time, and progress accounting to one record.""" + record.apply_result(result, duration_seconds=round(monotonic() - worker.started_at, 3)) + RunSupervisor._refresh_worker_progress(record, worker) def _cancel_active(self, active, records, manifest, manifest_path, reason: str) -> None: for collector_id, worker in tuple(active.items()): diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 8b06773d..1601d346 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -206,6 +206,8 @@ def test_preflight_plan_run_and_package(self) -> None: outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual("succeeded", outcome.manifest.status.value) self.assertEqual(1, len(outcome.manifest.artifact_list())) + self.assertIsNotNone(outcome.manifest.collectors[0].heartbeat_at) + self.assertIsNotNone(outcome.manifest.collectors[0].last_progress_at) package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) From 1490278b163ecdec09c218f7748539a847cb4197 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 21:59:44 +0300 Subject: [PATCH 098/316] docs: record completed isolation safeguards --- TODO.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 2a99122e..1adf8e07 100644 --- a/TODO.md +++ b/TODO.md @@ -278,7 +278,7 @@ that fails any mandatory rule is not a collector and must not be launched. - [ ] Require all expected failures to become typed result/error values rather than process exits. - [x] Require docstrings for the class and every public method. -- [ ] Reject import-time collection, threads, subprocesses, network calls, file +- [x] Reject import-time collection, threads, subprocesses, network calls, file writes, registry writes, prompts, or `sys.exit()` calls. #### Static and runtime preflight validation @@ -312,11 +312,11 @@ Windows platform allows. - [x] Launch each collector in its own worker process rather than importing and running all collectors in the main process. -- [ ] Give each collector a private working directory: +- [x] Give each collector a private working directory: `ACCESS/RUNS//collectors//`. -- [ ] Give each collector a private temporary directory, stdout/stderr capture, +- [x] Give each collector a private temporary directory, stdout/stderr capture, structured event channel, and artifact staging area. -- [ ] Pass only a serializable `CollectorContext`/request payload into the worker; +- [x] Pass only a serializable `CollectorContext`/request payload into the worker; do not share mutable engine objects across collectors. - [ ] Enforce declared timeout, memory/output limits, file-count limits, and cancellation through the supervisor. @@ -339,7 +339,7 @@ Windows platform allows. - [ ] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter configuration, install packages, update the repository, or launch another collector through the public context API. -- [ ] Provide a supervisor heartbeat and last-progress timestamp for every worker. +- [x] Provide a supervisor heartbeat and last-progress timestamp for every worker. - [ ] Make the final package include the isolation result and failure reason for every selected collector. From 9576c99db2695629421417fd43a94526b8eeb45e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:01:17 +0300 Subject: [PATCH 099/316] feat: validate collector metadata schemas --- logicytics/contracts.py | 47 ++++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 13 +++++++++ 2 files changed, 60 insertions(+) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 118b733f..ccba4718 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -11,6 +11,10 @@ CONTRACT_VERSION = "4.0" _CUSTOM_SPECIALTY = re.compile(r"^[a-z][a-z0-9_]{1,63}$") +_COLLECTOR_ID = re.compile(r"^(?:core|plugin)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?$") +_SEMANTIC_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") +_CONTRACT_VERSION = re.compile(r"^\d+\.\d+$") +_LABEL = re.compile(r"^[a-z][a-z0-9_]{0,63}$") class CollectorKind(StrEnum): @@ -101,6 +105,49 @@ class CollectorMetadata: minimum_contract_version: str = CONTRACT_VERSION parallel_safe: bool = True + def __post_init__(self) -> None: + """Reject malformed metadata before it can enter planning or runtime.""" + for name in ("id", "name", "version", "description", "author", "minimum_contract_version"): + value = getattr(self, name) + if not isinstance(value, str) or not value.strip() or "\n" in value or "\r" in value: + raise ValueError(f"metadata {name} must be a non-empty single-line string") + if not _COLLECTOR_ID.fullmatch(self.id): + raise ValueError("metadata id has an invalid schema") + if not _SEMANTIC_VERSION.fullmatch(self.version): + raise ValueError("metadata version must use semantic versioning") + if not _CONTRACT_VERSION.fullmatch(self.minimum_contract_version): + raise ValueError("metadata minimum_contract_version has an invalid schema") + if not isinstance(self.specialty, Specialty) and ( + not isinstance(self.specialty, str) or not _CUSTOM_SPECIALTY.fullmatch(self.specialty)): + raise ValueError("metadata specialty has an invalid schema") + self._validate_labels("supported_platforms", self.supported_platforms, require_value=True) + self._validate_labels("sensitive_data_categories", self.sensitive_data_categories) + self._validate_labels("default_profiles", self.default_profiles, require_value=True) + if not isinstance(self.capabilities, tuple) or not all( + isinstance(capability, Capability) for capability in self.capabilities): + raise ValueError("metadata capabilities must be a tuple of Capability values") + if not isinstance(self.dependencies, tuple) or not all( + isinstance(dependency, str) and _COLLECTOR_ID.fullmatch(dependency) + for dependency in self.dependencies): + raise ValueError("metadata dependencies must be collector IDs") + if len(set(self.dependencies)) != len(self.dependencies) or self.id in self.dependencies: + raise ValueError("metadata dependencies must be unique and cannot include the collector itself") + for name in ("timeout_seconds", "maximum_output_bytes", "maximum_artifact_files"): + value = getattr(self, name) + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise ValueError(f"metadata {name} must be a positive integer") + if not isinstance(self.parallel_safe, bool): + raise ValueError("metadata parallel_safe must be boolean") + + @staticmethod + def _validate_labels(name: str, values: tuple[str, ...], *, require_value: bool = False) -> None: + """Require unique lower-snake-case labels for selector-like metadata fields.""" + if not isinstance(values, tuple) or (require_value and not values) or not all( + isinstance(value, str) and _LABEL.fullmatch(value) for value in values): + raise ValueError(f"metadata {name} must be a tuple of lowercase labels") + if len(set(values)) != len(values): + raise ValueError(f"metadata {name} must not contain duplicates") + def to_dict(self) -> dict[str, Any]: """Return JSON-safe metadata.""" data = asdict(self) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 1601d346..8b279e8f 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -87,6 +87,19 @@ def test_custom_specialty_is_plugin_only(self) -> None: with self.assertRaises(ValueError): CollectorMetadata.from_dict(metadata) + def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: + """Collector metadata must be a complete typed declaration rather than free text.""" + common = dict( + id="core.system.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, + description="Example collector.", author="tests", + ) + with self.assertRaisesRegex(ValueError, "id has an invalid schema"): + CollectorMetadata(**{**common, "id": "example"}) + with self.assertRaisesRegex(ValueError, "semantic versioning"): + CollectorMetadata(**{**common, "version": "four"}) + with self.assertRaisesRegex(ValueError, "maximum_artifact_files"): + CollectorMetadata(**{**common, "maximum_artifact_files": 0}) + def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, object]]] = [] From e15aa0d7509c04bfbb8d5784c809600bbed26bce Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:01:40 +0300 Subject: [PATCH 100/316] docs: track metadata schema validation --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 1adf8e07..7b27858c 100644 --- a/TODO.md +++ b/TODO.md @@ -241,7 +241,7 @@ that fails any mandatory rule is not a collector and must not be launched. - [ ] Require plugin metadata to additionally declare requested capabilities, privilege level, sensitive-data categories, network access, estimated cost, timeout, maximum output, and minimum contract version. -- [ ] Require IDs, names, specialties, and versions to match their schemas and +- [x] Require IDs, names, specialties, and versions to match their schemas and reject duplicates or unsupported contract versions before launch. #### Specialty rules From cc657fef3e4a7acf8edda2f9ba32b171f124ee06 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:03:58 +0300 Subject: [PATCH 101/316] feat: restrict preflight worker environment --- logicytics/discovery.py | 45 ++++++++++++++++++++------------ tests/test_core_functionality.py | 25 ++++++++++++++++++ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index e3fcfc12..5d357145 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -8,6 +8,7 @@ import re import subprocess import sys +import tempfile from dataclasses import dataclass, field from pathlib import Path @@ -264,10 +265,7 @@ def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: engine_root = Path(__file__).resolve().parent.parent - inherited_pythonpath = os.environ.get("PYTHONPATH", "") - pythonpath = os.pathsep.join( - value for value in (str(engine_root), str(project_root), inherited_pythonpath) if value - ) + pythonpath = os.pathsep.join((str(engine_root), str(project_root))) command = [ sys.executable, "-m", @@ -276,19 +274,32 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: candidate.kind.value, candidate.expected_class, ] - try: - completed = subprocess.run( - command, - cwd=project_root, - capture_output=True, - text=True, - timeout=10, - check=False, - env={**os.environ, "PYTHONPATH": pythonpath, "LOGICYTICS_VALIDATION": "1"}, - ) - except (OSError, subprocess.TimeoutExpired) as error: - candidate.runtime_error = f"validation worker failed: {error}" - return + with tempfile.TemporaryDirectory(prefix="logicytics-preflight-") as temporary: + probe_directory = Path(temporary) + environment = { + "PYTHONPATH": pythonpath, + "LOGICYTICS_VALIDATION": "1", + "PATH": os.environ.get("PATH", ""), + "TEMP": str(probe_directory), + "TMP": str(probe_directory), + "PYTHONUTF8": "1", + } + for name in ("SYSTEMROOT", "WINDIR", "COMSPEC"): + if value := os.environ.get(name): + environment[name] = value + try: + completed = subprocess.run( + command, + cwd=probe_directory, + capture_output=True, + text=True, + timeout=10, + check=False, + env=environment, + ) + except (OSError, subprocess.TimeoutExpired) as error: + candidate.runtime_error = f"validation worker failed: {error}" + return if completed.returncode != 0: candidate.runtime_error = completed.stderr.strip() or "validation worker rejected collector" return diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 8b279e8f..799faa7d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -6,6 +6,7 @@ import unittest import zipfile from pathlib import Path +from unittest.mock import patch from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command @@ -391,6 +392,30 @@ def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> self.assertTrue(any("forbidden import-time call: input" in error for error in errors)) self.assertTrue(any("forbidden import-time call: open" in error for error in errors)) + def test_preflight_probe_uses_a_restricted_environment(self) -> None: + """Validation probes expose only documented preflight variables to collectors.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + source = _COLLECTOR.replace( + "from pathlib import Path", + "import os\nfrom pathlib import Path", + ).replace( + " return ValidationResult(True)", + " if os.environ.get('LOGICYTICS_VALIDATION') != '1':\n" + " return ValidationResult(False, reasons=('validation flag missing',))\n" + " if os.environ.get('LOGICYTICS_TEST_SECRET') is not None:\n" + " return ValidationResult(False, reasons=('caller environment leaked',))\n" + " return ValidationResult(True)", + 1, + ) + collector_path.write_text(source, encoding="utf-8") + with patch.dict("os.environ", {"LOGICYTICS_TEST_SECRET": "must-not-leak"}): + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + def test_capability_gate_requires_explicit_approval(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From b29c4c7fc03b0b8657f734d565cbc47b674d5a88 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:04:20 +0300 Subject: [PATCH 102/316] docs: track restricted validation workers --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 7b27858c..1b20dd05 100644 --- a/TODO.md +++ b/TODO.md @@ -287,7 +287,7 @@ that fails any mandatory rule is not a collector and must not be launched. - [ ] Validate path, filename, module docstring, imports, public names, class inheritance, class name, metadata, method signatures, type annotations, and forbidden top-level statements. -- [ ] Import each candidate in a short-lived validation subprocess with collection +- [x] Import each candidate in a short-lived validation subprocess with collection disabled and with a restricted environment. - [x] Instantiate the class only after static validation succeeds. - [ ] Validate metadata, dependencies, supported platform, requested capabilities, From c252696de6bd10e504006770ce76d33718a1a0f4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:05:25 +0300 Subject: [PATCH 103/316] fix: clean terminal worker scratch directories --- logicytics/runtime.py | 16 +++++++++++++++- tests/test_core_functionality.py | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 765d52bc..a4831331 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -8,6 +8,7 @@ import multiprocessing import os import queue +import shutil import subprocess import traceback from dataclasses import asdict, dataclass @@ -287,6 +288,7 @@ def _supervise( if worker is not None: worker.process.join(timeout=1) self._apply_worker_result(records[collector_id], _result_from_dict(message["result"]), worker) + self._cleanup_worker_temporary_directory(worker) run_logger.event("info", "collector_finished", collector_id=collector_id) write_manifest(manifest_path, manifest) @@ -301,9 +303,10 @@ def _supervise( CollectorResult(CollectorStatus.FAILED, "collector exceeded its declared timeout"), worker, ) + self._cleanup_worker_temporary_directory(worker) run_logger.event("error", "collector_timed_out", collector_id=collector_id) write_manifest(manifest_path, manifest) - elif not worker.process.is_alive() and worker.process.exitcode not in (None, 0): + elif not worker.process.is_alive(): worker.process.join(timeout=1) active.pop(collector_id) self._apply_worker_result( @@ -311,10 +314,20 @@ def _supervise( CollectorResult(CollectorStatus.FAILED, "collector exited without a result"), worker, ) + self._cleanup_worker_temporary_directory(worker) run_logger.event("error", "collector_exited_without_result", collector_id=collector_id) write_manifest(manifest_path, manifest) sleep(0.01) + @staticmethod + def _cleanup_worker_temporary_directory(worker: _ActiveWorker) -> None: + """Remove only a terminal worker's private scratch directory, never its logs.""" + temporary_directory = worker.workspace / "tmp" + try: + shutil.rmtree(temporary_directory, ignore_errors=True) + except OSError: + pass + @staticmethod def _terminate_process_tree(process: multiprocessing.Process) -> None: """Terminate a worker and, on Windows, any subprocesses it created.""" @@ -374,6 +387,7 @@ def _cancel_active(self, active, records, manifest, manifest_path, reason: str) CollectorResult(CollectorStatus.CANCELLED, reason), worker, ) + self._cleanup_worker_temporary_directory(worker) write_manifest(manifest_path, manifest) def _cancel_records(self, records, manifest, manifest_path, reason: str) -> None: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 799faa7d..ee2fd6cb 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -222,6 +222,7 @@ def test_preflight_plan_run_and_package(self) -> None: self.assertEqual(1, len(outcome.manifest.artifact_list())) self.assertIsNotNone(outcome.manifest.collectors[0].heartbeat_at) self.assertIsNotNone(outcome.manifest.collectors[0].last_progress_at) + self.assertFalse((outcome.run_directory / "collectors" / "core_system_system_info" / "tmp").exists()) package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) From 59f26866dfb20f254e9bd684d8c38e59cbb7f3ea Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:06:53 +0300 Subject: [PATCH 104/316] feat: verify atomic evidence packages --- logicytics/packaging.py | 73 ++++++++++++++++++++++++++------ tests/test_core_functionality.py | 6 +++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index d09e97e2..099f54aa 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import zipfile from pathlib import Path @@ -18,6 +19,45 @@ def _summary(manifest: RunManifest) -> str: return "\n".join(lines) + "\n" +def _artifact_sources(outcome: RunOutcome) -> list[tuple[Path, str]]: + """Resolve and verify every manifest-declared artifact before packaging it.""" + sources: list[tuple[Path, str]] = [] + archive_names: set[str] = set() + for artifact in outcome.manifest.artifact_list(): + source = outcome.run_directory / "artifacts" / artifact.relative_path + archive_name = f"artifacts/{artifact.relative_path}" + if archive_name in archive_names: + raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") + if not source.is_file(): + raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") + if source.stat().st_size != artifact.size_bytes or sha256_file(source) != artifact.sha256: + raise ValueError(f"manifest artifact verification failed: {artifact.relative_path}") + archive_names.add(archive_name) + sources.append((source, archive_name)) + return sources + + +def _log_sources(run_directory: Path) -> list[tuple[Path, str]]: + """Return only run-owned structured logs, preserving their relative paths.""" + sources: list[tuple[Path, str]] = [] + for directory in (run_directory / "logs", run_directory / "collectors"): + if not directory.exists(): + continue + for path in sorted(directory.rglob("*.jsonl")): + sources.append((path, path.relative_to(run_directory).as_posix())) + return sources + + +def _verify_archive(package_path: Path, expected_names: set[str]) -> None: + """Confirm an atomically written package contains exactly its declared inputs.""" + with zipfile.ZipFile(package_path) as archive: + names = set(archive.namelist()) + if names != expected_names: + raise ValueError("package contents do not match its manifest-led input set") + if archive.testzip() is not None: + raise ValueError("package integrity verification failed") + + def package_run(outcome: RunOutcome) -> tuple[Path, Path]: """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" package_directory = outcome.run_directory.parent.parent / "PACKAGES" @@ -29,17 +69,24 @@ def package_run(outcome: RunOutcome) -> tuple[Path, Path]: summary_path = outcome.run_directory / "summary.txt" summary_path.write_text(_summary(outcome.manifest), encoding="utf-8") - with zipfile.ZipFile(package_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.write(outcome.manifest_path, "manifest.json") - archive.write(summary_path, "summary.txt") - for artifact in outcome.manifest.artifact_list(): - source = outcome.run_directory / "artifacts" / artifact.relative_path - if not source.is_file(): - raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") - archive.write(source, f"artifacts/{artifact.relative_path}") - for log_path in sorted((outcome.run_directory / "logs").rglob("*.jsonl")): - archive.write(log_path, log_path.relative_to(outcome.run_directory).as_posix()) - for collector_path in sorted((outcome.run_directory / "collectors").rglob("*.jsonl")): - archive.write(collector_path, collector_path.relative_to(outcome.run_directory).as_posix()) - hash_path.write_text(f"{sha256_file(package_path)} {package_path.name}\n", encoding="ascii") + artifact_sources = _artifact_sources(outcome) + log_sources = _log_sources(outcome.run_directory) + expected_names = {"manifest.json", "summary.txt"} + expected_names.update(archive_name for _, archive_name in artifact_sources) + expected_names.update(archive_name for _, archive_name in log_sources) + temporary_package = package_path.with_suffix(".zip.tmp") + try: + with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.write(outcome.manifest_path, "manifest.json") + archive.write(summary_path, "summary.txt") + for source, archive_name in (*artifact_sources, *log_sources): + archive.write(source, archive_name) + _verify_archive(temporary_package, expected_names) + os.replace(temporary_package, package_path) + finally: + if temporary_package.exists(): + temporary_package.unlink() + temporary_hash = hash_path.with_suffix(".sha256.tmp") + temporary_hash.write_text(f"{sha256_file(package_path)} {package_path.name}\n", encoding="ascii") + os.replace(temporary_hash, hash_path) return package_path, hash_path diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index ee2fd6cb..a8cd44b0 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -226,6 +226,12 @@ def test_preflight_plan_run_and_package(self) -> None: package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) + self.assertEqual(f"{package_path.name}", hash_path.read_text(encoding="ascii").split()[1]) + with zipfile.ZipFile(package_path) as archive: + self.assertIsNone(archive.testzip()) + self.assertIn("manifest.json", archive.namelist()) + self.assertIn("summary.txt", archive.namelist()) + self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("artifacts/")])) def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: with tempfile.TemporaryDirectory() as temporary: From a39f92955fb82ecd7e08597cda4c442cce604608 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:07:28 +0300 Subject: [PATCH 105/316] docs: track atomic package finalization --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 1b20dd05..297f447b 100644 --- a/TODO.md +++ b/TODO.md @@ -494,7 +494,7 @@ Windows platform allows. - [ ] Preserve artifact provenance: collector ID, source category, collection time, and transformation steps. - [ ] Verify package contents against the manifest before marking the run complete. -- [ ] Use atomic writes and temporary package names so interrupted packaging cannot +- [x] Use atomic writes and temporary package names so interrupted packaging cannot look like a finished result. - [ ] Keep raw evidence, derived reports, logs, hashes, and metadata in separate package sections. From 4d31e2ae35da6062d505f12c12d12dad2b3d5e9d Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:09:32 +0300 Subject: [PATCH 106/316] feat: enrich run manifest provenance --- logicytics/manifest.py | 22 ++++++++++++++++++++-- tests/test_core_functionality.py | 4 ++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 56597944..0e947347 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -5,12 +5,14 @@ import json import os import platform +import ctypes +import getpass from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Mapping -from logicytics.contracts import Artifact, CollectorResult, RunStatus +from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus def utc_now() -> str: @@ -58,6 +60,8 @@ class RunManifest: configuration: Mapping[str, Any] collectors: list[CollectorRecord] host: Mapping[str, str] + engine_version: str = CONTRACT_VERSION + action: str = "run" finished_at: str | None = None package: Mapping[str, str] | None = None total_artifact_bytes: int = 0 @@ -79,7 +83,13 @@ def create( configuration=configuration, collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], - host={"platform": sys_platform(), "hostname": platform.node(), "python": platform.python_version()}, + host={ + "platform": sys_platform(), + "hostname": platform.node(), + "python": platform.python_version(), + "user": getpass.getuser(), + "is_administrator": _privilege_label(), + }, ) def finalize_status(self) -> None: @@ -116,6 +126,14 @@ def sys_platform() -> str: return platform.system().lower() +def _privilege_label() -> str: + """Return the local elevation state without starting external tools.""" + try: + return "true" if ctypes.windll.shell32.IsUserAnAdmin() else "false" + except (AttributeError, OSError): + return "unknown" + + def write_manifest(path: Path, manifest: RunManifest) -> None: """Write a manifest atomically so partial writes never resemble completed runs.""" path.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a8cd44b0..b2f2a776 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -220,6 +220,10 @@ def test_preflight_plan_run_and_package(self) -> None: outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual("succeeded", outcome.manifest.status.value) self.assertEqual(1, len(outcome.manifest.artifact_list())) + self.assertEqual("run", outcome.manifest.action) + self.assertEqual("4.0", outcome.manifest.engine_version) + self.assertIn("user", outcome.manifest.host) + self.assertIn("is_administrator", outcome.manifest.host) self.assertIsNotNone(outcome.manifest.collectors[0].heartbeat_at) self.assertIsNotNone(outcome.manifest.collectors[0].last_progress_at) self.assertFalse((outcome.run_directory / "collectors" / "core_system_system_info" / "tmp").exists()) From cebf51df7d95ba6cd3829d67aa9de0f0cdefbbd9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:09:53 +0300 Subject: [PATCH 107/316] docs: track run manifest metadata --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 297f447b..a4c4ad2f 100644 --- a/TODO.md +++ b/TODO.md @@ -870,7 +870,7 @@ appropriate default/deep/non-Python mode. - [ ] Define stable output names and formats for every collector. - [ ] Keep logs, hashes, packages, graphs, CSVs, HTML, text, and copied evidence discoverable under one run-specific output tree. -- [ ] Include collection metadata: version, action, start/end times, host, user, +- [x] Include collection metadata: version, action, start/end times, host, user, privilege state, enabled collectors, skipped collectors, and failures. ## 8. v4.0 delivery phases From 36e6ebcc30c310c847415869412686cfb581221d Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:12:53 +0300 Subject: [PATCH 108/316] feat: package completed runs in supervisor --- logicytics/cli.py | 9 ++++---- logicytics/packaging.py | 38 ++++++++++++++++++++------------ logicytics/runtime.py | 15 +++++++++++++ tests/test_core_functionality.py | 3 +++ 4 files changed, 47 insertions(+), 18 deletions(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index eac5750c..196b1233 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -16,7 +16,6 @@ from logicytics.discovery import preflight from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError -from logicytics.packaging import package_run from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -158,9 +157,11 @@ def main(argv: list[str] | None = None) -> int: encoding="utf-8", ) print(f"Performance: {performance_path}") - if configuration.runtime.package_completed_runs: - package_path, hash_path = package_run(outcome) - print(f"Package: {package_path}\nSHA-256: {hash_path}") + if outcome.manifest.package and "path" in outcome.manifest.package: + print( + f"Package: {outcome.manifest.package['path']}\n" + f"SHA-256: {outcome.manifest.package.get('sha256_path', 'unavailable')}" + ) print(f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}") return 0 if outcome.manifest.status.value == "succeeded" else 1 except (LogicyticsError, PermissionError, ValueError) as error: diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 099f54aa..3bfe1b4b 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -5,10 +5,13 @@ import os import zipfile from pathlib import Path +from typing import TYPE_CHECKING from logicytics.artifacts import sha256_file from logicytics.manifest import RunManifest, write_manifest -from logicytics.runtime import RunOutcome + +if TYPE_CHECKING: + from logicytics.runtime import RunOutcome def _summary(manifest: RunManifest) -> str: @@ -19,12 +22,12 @@ def _summary(manifest: RunManifest) -> str: return "\n".join(lines) + "\n" -def _artifact_sources(outcome: RunOutcome) -> list[tuple[Path, str]]: +def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[Path, str]]: """Resolve and verify every manifest-declared artifact before packaging it.""" sources: list[tuple[Path, str]] = [] archive_names: set[str] = set() - for artifact in outcome.manifest.artifact_list(): - source = outcome.run_directory / "artifacts" / artifact.relative_path + for artifact in manifest.artifact_list(): + source = run_directory / "artifacts" / artifact.relative_path archive_name = f"artifacts/{artifact.relative_path}" if archive_name in archive_names: raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") @@ -58,26 +61,26 @@ def _verify_archive(package_path: Path, expected_names: set[str]) -> None: raise ValueError("package integrity verification failed") -def package_run(outcome: RunOutcome) -> tuple[Path, Path]: +def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: Path) -> tuple[Path, Path]: """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" - package_directory = outcome.run_directory.parent.parent / "PACKAGES" + package_directory = run_directory.parent.parent / "PACKAGES" package_directory.mkdir(parents=True, exist_ok=True) - package_path = package_directory / f"{outcome.manifest.run_id}.zip" + package_path = package_directory / f"{manifest.run_id}.zip" hash_path = package_path.with_suffix(".zip.sha256") - outcome.manifest.package = {"path": str(package_path)} - write_manifest(outcome.manifest_path, outcome.manifest) - summary_path = outcome.run_directory / "summary.txt" - summary_path.write_text(_summary(outcome.manifest), encoding="utf-8") - artifact_sources = _artifact_sources(outcome) - log_sources = _log_sources(outcome.run_directory) + summary_path = run_directory / "summary.txt" + artifact_sources = _artifact_sources(run_directory, manifest) + log_sources = _log_sources(run_directory) + manifest.package = {"path": str(package_path)} + write_manifest(manifest_path, manifest) + summary_path.write_text(_summary(manifest), encoding="utf-8") expected_names = {"manifest.json", "summary.txt"} expected_names.update(archive_name for _, archive_name in artifact_sources) expected_names.update(archive_name for _, archive_name in log_sources) temporary_package = package_path.with_suffix(".zip.tmp") try: with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.write(outcome.manifest_path, "manifest.json") + archive.write(manifest_path, "manifest.json") archive.write(summary_path, "summary.txt") for source, archive_name in (*artifact_sources, *log_sources): archive.write(source, archive_name) @@ -89,4 +92,11 @@ def package_run(outcome: RunOutcome) -> tuple[Path, Path]: temporary_hash = hash_path.with_suffix(".sha256.tmp") temporary_hash.write_text(f"{sha256_file(package_path)} {package_path.name}\n", encoding="ascii") os.replace(temporary_hash, hash_path) + manifest.package = {"path": str(package_path), "sha256_path": str(hash_path)} + write_manifest(manifest_path, manifest) return package_path, hash_path + + +def package_run(outcome: "RunOutcome") -> tuple[Path, Path]: + """Compatibility wrapper for callers holding a complete runtime outcome.""" + return package_manifest(outcome.run_directory, outcome.manifest, outcome.manifest_path) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index a4831331..b7a81d0a 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -11,6 +11,7 @@ import shutil import subprocess import traceback +import zipfile from dataclasses import asdict, dataclass from pathlib import Path from time import monotonic, sleep @@ -28,6 +29,7 @@ ) from logicytics.logging import FileEventLogger from logicytics.manifest import RunManifest, write_manifest, utc_now +from logicytics.packaging import package_manifest from logicytics.planner import RunPlan @@ -214,6 +216,19 @@ def run(self, plan: RunPlan) -> RunOutcome: self._cancel_records(records, manifest, manifest_path, "run cancelled by user") manifest.finalize_status() + if self.configuration.runtime.package_completed_runs: + try: + package_path, hash_path = package_manifest(run_directory, manifest, manifest_path) + run_logger.event( + "info", + "run_packaged", + package_path=str(package_path), + hash_path=str(hash_path), + ) + except (OSError, ValueError, zipfile.BadZipFile) as error: + manifest.status = RunStatus.FAILED + manifest.package = {"status": "failed", "error": f"{type(error).__name__}: {error}"} + run_logger.event("error", "run_packaging_failed", error_type=type(error).__name__) write_manifest(manifest_path, manifest) run_logger.event("info", "run_finished", status=manifest.status.value, artifacts=manifest.total_artifact_bytes) return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index b2f2a776..62b22360 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -227,6 +227,9 @@ def test_preflight_plan_run_and_package(self) -> None: self.assertIsNotNone(outcome.manifest.collectors[0].heartbeat_at) self.assertIsNotNone(outcome.manifest.collectors[0].last_progress_at) self.assertFalse((outcome.run_directory / "collectors" / "core_system_system_info" / "tmp").exists()) + self.assertIsNotNone(outcome.manifest.package) + self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) + self.assertTrue(Path(outcome.manifest.package["sha256_path"]).is_file()) package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) From 66f3aa03cfd40f7c3c02cc9cf24a54a4ad68f11a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:13:15 +0300 Subject: [PATCH 109/316] docs: track automatic run packaging --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index a4c4ad2f..bfbcbccc 100644 --- a/TODO.md +++ b/TODO.md @@ -930,7 +930,7 @@ appropriate default/deep/non-Python mode. - [ ] A failed collector cannot silently make the run appear successful. - [ ] A cancelled run leaves a recoverable partial-run report and cleans only its own temporary data. -- [ ] A normal run creates a package, a matching SHA-256 hash, and a run manifest. +- [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. - [ ] Debug/update/developer tools are separated from normal collection behavior. - [ ] The final v4.0 release is documented as a complete recreation of the feature surface listed here. From fd42c314425f71d6af5bbb50f9674711bfa36a4e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:14:41 +0300 Subject: [PATCH 110/316] test: cover failed collector run outcome --- tests/test_core_functionality.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 62b22360..a5ee2976 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -240,6 +240,33 @@ def test_preflight_plan_run_and_package(self) -> None: self.assertIn("summary.txt", archive.namelist()) self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("artifacts/")])) + def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: + """An isolated collector crash must produce a durable failed run outcome.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + 'output = context.workspace / "system.txt"\n' + ' output.write_text("ok\\n", encoding="utf-8")\n' + ' artifact = context.artifacts.register_file(output, media_type="text/plain")\n' + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + 'raise RuntimeError("intentional test collector failure")', + ), + encoding="utf-8", + ) + report = preflight(root) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.status.value) + record = outcome.manifest.collectors[0] + self.assertEqual("failed", record.status) + self.assertTrue(any("RuntimeError" in error for error in record.errors)) + self.assertIsNotNone(outcome.manifest.package) + self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) + def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From 17989a8f02ad336befc4ba045467ab31a106a54b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:15:02 +0300 Subject: [PATCH 111/316] docs: track failed collector outcomes --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index bfbcbccc..86f25983 100644 --- a/TODO.md +++ b/TODO.md @@ -927,7 +927,7 @@ appropriate default/deep/non-Python mode. - [ ] Every collector can be run directly and through the orchestrator. - [ ] Every mode has an explicit collector inclusion matrix. - [ ] Every output has a documented path, format, and retention rule. -- [ ] A failed collector cannot silently make the run appear successful. +- [x] A failed collector cannot silently make the run appear successful. - [ ] A cancelled run leaves a recoverable partial-run report and cleans only its own temporary data. - [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. From c61c0aa4faa11f7830ecd6b424e9f4ef7bb0d7d7 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:16:26 +0300 Subject: [PATCH 112/316] test: cover cancelled run recovery --- tests/test_core_functionality.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a5ee2976..d6417021 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -267,6 +267,27 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N self.assertIsNotNone(outcome.manifest.package) self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) + def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: + """Keyboard cancellation must affect only this run and preserve its partial report.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + report = preflight(root) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + supervisor = RunSupervisor(root, default_config(root)) + with patch.object(supervisor, "_supervise", side_effect=KeyboardInterrupt): + outcome = supervisor.run(plan) + self.assertEqual("cancelled", outcome.manifest.status.value) + record = outcome.manifest.collectors[0] + self.assertEqual("cancelled", record.status) + self.assertEqual("run cancelled by user", record.summary) + self.assertTrue((outcome.run_directory / ".cancelled").is_file()) + self.assertIsNotNone(outcome.manifest.package) + self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) + def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) From a2c36bef329c8de1200d9969b55400b641242ec3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:16:47 +0300 Subject: [PATCH 113/316] docs: track cancelled run recovery --- TODO.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 86f25983..9db915c2 100644 --- a/TODO.md +++ b/TODO.md @@ -928,7 +928,7 @@ appropriate default/deep/non-Python mode. - [ ] Every mode has an explicit collector inclusion matrix. - [ ] Every output has a documented path, format, and retention rule. - [x] A failed collector cannot silently make the run appear successful. -- [ ] A cancelled run leaves a recoverable partial-run report and cleans only its +- [x] A cancelled run leaves a recoverable partial-run report and cleans only its own temporary data. - [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. - [ ] Debug/update/developer tools are separated from normal collection behavior. From a87098039d18438020de381e2a66768f119f22ca Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:18:17 +0300 Subject: [PATCH 114/316] feat: validate network collector settings --- logicytics/configuration.py | 28 ++++++++++++++++++++++++++++ tests/test_core_functionality.py | 20 ++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 9bca78fd..4f3d9816 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -12,6 +12,33 @@ SCHEMA_VERSION = 4 +def _positive_integer(value: object, *, minimum: int, maximum: int) -> bool: + """Whether a JSON value is a bounded integer rather than a boolean or coercion.""" + return isinstance(value, int) and not isinstance(value, bool) and minimum <= value <= maximum + + +def _bounded_number(value: object, *, minimum: float, maximum: float) -> bool: + """Whether a JSON value is a bounded finite numeric setting.""" + return isinstance(value, (int, float)) and not isinstance(value, bool) and minimum <= value <= maximum + + +def _validate_collector_settings(settings: Mapping[str, Mapping[str, Any]]) -> None: + """Validate documented bounded settings before any worker receives them.""" + bandwidth = settings.get("core.network.bandwidth_sample", {}) + for key, maximum in (("sample_count", 10), ("interval_seconds", 5)): + if key in bandwidth and not _positive_integer(bandwidth[key], minimum=1, maximum=maximum): + raise PlanError(f"core.network.bandwidth_sample.{key} must be an integer from 1 to {maximum}") + + capture = settings.get("core.packet.packet_capture", {}) + if "packet_count" in capture and not _positive_integer(capture["packet_count"], minimum=1, maximum=10_000): + raise PlanError("core.packet.packet_capture.packet_count must be an integer from 1 to 10000") + if "timeout_seconds" in capture and not _bounded_number(capture["timeout_seconds"], minimum=1, maximum=60): + raise PlanError("core.packet.packet_capture.timeout_seconds must be a number from 1 to 60") + if "interface" in capture and ( + not isinstance(capture["interface"], str) or not capture["interface"].strip()): + raise PlanError("core.packet.packet_capture.interface must be a non-empty string") + + @dataclass(frozen=True, slots=True) class RuntimeSettings: """Engine-wide limits that apply before a collector is started.""" @@ -83,6 +110,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi for key, value in collector_settings.items() ): raise PlanError("collectors configuration must map collector IDs to objects") + _validate_collector_settings(collector_settings) return AppConfig( schema_version=schema_version, runtime=RuntimeSettings( diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d6417021..d0892a13 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -166,6 +166,26 @@ def test_configuration_schema_version_is_enforced(self) -> None: config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") self.assertEqual(4, load_config(root).schema_version) + def test_configuration_validates_bounded_network_and_packet_settings(self) -> None: + """Collector-specific settings fail early rather than being silently coerced at runtime.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"collectors":{"core.network.bandwidth_sample":' + '{"sample_count":11}}}', + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "sample_count"): + load_config(root) + config_path.write_text( + '{"schema_version":4,"collectors":{"core.packet.packet_capture":' + '{"packet_count":10,"timeout_seconds":5,"interface":"127.0.0.1"}}}', + encoding="utf-8", + ) + settings = load_config(root).settings_for("core.packet.packet_capture") + self.assertEqual(10, settings["packet_count"]) + def test_run_parser_accepts_performance_check(self) -> None: """The run command must expose the performance mode used by the request builder.""" arguments = _parser().parse_args(["run", "--performance-check"]) From ab8132b5060cad3661a4b5d2a9c41e00901f3ca3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:19:55 +0300 Subject: [PATCH 115/316] feat: configure packet capture retry window --- core/packet/packet_capture.py | 28 +++++++++++++++++++++++----- logicytics/configuration.py | 3 +++ tests/test_core_functionality.py | 3 ++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index 1702c7e0..a2f55df3 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -54,10 +54,14 @@ def validate(self, context: CollectorContext) -> ValidationResult: try: count = int(context.settings.get("packet_count", 100)) timeout = float(context.settings.get("timeout_seconds", 10)) + retry_window = float(context.settings.get("retry_window_seconds", 0)) except (TypeError, ValueError): - return ValidationResult(False, reasons=("packet_count and timeout_seconds must be numeric",)) - if not 1 <= count <= 10_000 or not 1 <= timeout <= 60: - return ValidationResult(False, reasons=("packet_count must be 1-10000 and timeout_seconds must be 1-60",)) + return ValidationResult(False, reasons=("packet_count, timeout_seconds, and retry_window_seconds must be numeric",)) + if not 1 <= count <= 10_000 or not 1 <= timeout <= 60 or not 0 <= retry_window <= 60: + return ValidationResult( + False, + reasons=("packet_count must be 1-10000, timeout_seconds 1-60, and retry_window_seconds 0-60",), + ) return ValidationResult(True) def collect(self, context: CollectorContext) -> CollectorResult: @@ -66,8 +70,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before packet capture") count = int(context.settings.get("packet_count", 100)) timeout = float(context.settings.get("timeout_seconds", 10)) + retry_window = float(context.settings.get("retry_window_seconds", 0)) interface = str(context.settings.get("interface", socket.gethostbyname(socket.gethostname()))) - context.report_progress("packet_capture_started", interface=interface, packet_count=count) + context.report_progress( + "packet_capture_started", + interface=interface, + packet_count=count, + retry_window_seconds=retry_window, + ) capture: socket.socket | None = None observations: list[dict[str, str]] = [] try: @@ -76,13 +86,21 @@ def collect(self, context: CollectorContext) -> CollectorResult: capture.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON) deadline = time.monotonic() + timeout + retry_deadline = time.monotonic() + retry_window while len(observations) < count and time.monotonic() < deadline: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during packet capture") ready, _, _ = select.select([capture], [], [], min(1.0, deadline - time.monotonic())) if not ready: continue - row = _packet_row(capture.recv(65_535)) + try: + payload = capture.recv(65_535) + except OSError: + if time.monotonic() < retry_deadline: + time.sleep(0.1) + continue + raise + row = _packet_row(payload) if row is not None: observations.append(row) except PermissionError as error: diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 4f3d9816..8b8c419f 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -34,6 +34,9 @@ def _validate_collector_settings(settings: Mapping[str, Mapping[str, Any]]) -> N raise PlanError("core.packet.packet_capture.packet_count must be an integer from 1 to 10000") if "timeout_seconds" in capture and not _bounded_number(capture["timeout_seconds"], minimum=1, maximum=60): raise PlanError("core.packet.packet_capture.timeout_seconds must be a number from 1 to 60") + if "retry_window_seconds" in capture and not _bounded_number( + capture["retry_window_seconds"], minimum=0, maximum=60): + raise PlanError("core.packet.packet_capture.retry_window_seconds must be a number from 0 to 60") if "interface" in capture and ( not isinstance(capture["interface"], str) or not capture["interface"].strip()): raise PlanError("core.packet.packet_capture.interface must be a non-empty string") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d0892a13..efc7f65d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -180,11 +180,12 @@ def test_configuration_validates_bounded_network_and_packet_settings(self) -> No load_config(root) config_path.write_text( '{"schema_version":4,"collectors":{"core.packet.packet_capture":' - '{"packet_count":10,"timeout_seconds":5,"interface":"127.0.0.1"}}}', + '{"packet_count":10,"timeout_seconds":5,"retry_window_seconds":2,"interface":"127.0.0.1"}}}', encoding="utf-8", ) settings = load_config(root).settings_for("core.packet.packet_capture") self.assertEqual(10, settings["packet_count"]) + self.assertEqual(2, settings["retry_window_seconds"]) def test_run_parser_accepts_performance_check(self) -> None: """The run command must expose the performance mode used by the request builder.""" From af481455c9efd13c479e0a6cd4d45b73ed09c324 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:20:14 +0300 Subject: [PATCH 116/316] docs: track collector sampling settings --- TODO.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 9db915c2..c9aaacd0 100644 --- a/TODO.md +++ b/TODO.md @@ -863,8 +863,8 @@ appropriate default/deep/non-Python mode. debug output. - [ ] Recreate memory-dump settings for file-size limit, safety margin, and dump directory. -- [ ] Recreate network sampling settings for count and interval. -- [ ] Recreate packet-sniffer settings for interface, packet count, timeout, and +- [x] Recreate network sampling settings for count and interval. +- [x] Recreate packet-sniffer settings for interface, packet count, timeout, and retry duration. - [x] Version the configuration schema and provide migration/default handling. - [ ] Define stable output names and formats for every collector. From 0eaa4eb3ac8583b5cf3c829b433be89abe1d5972 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:36:12 +0300 Subject: [PATCH 117/316] fix: reject non-boolean packaging configuration --- logicytics/configuration.py | 5 ++++- tests/test_core_functionality.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 8b8c419f..c35149ca 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -106,6 +106,9 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError("worker limits must be integers") if not 1 <= default_workers <= maximum_workers <= 64: raise PlanError("worker limits must satisfy 1 <= default <= maximum <= 64") + package_completed_runs = runtime_raw.get("package_completed_runs", True) + if not isinstance(package_completed_runs, bool): + raise PlanError("runtime package_completed_runs must be boolean") collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( @@ -120,7 +123,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi output_root=output_root, default_max_workers=default_workers, maximum_workers=maximum_workers, - package_completed_runs=bool(runtime_raw.get("package_completed_runs", True)), + package_completed_runs=package_completed_runs, ), collector_settings=collector_settings, ) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index efc7f65d..f3051152 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -165,6 +165,9 @@ def test_configuration_schema_version_is_enforced(self) -> None: load_config(root) config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") self.assertEqual(4, load_config(root).schema_version) + config_path.write_text('{"schema_version":4,"runtime":{"package_completed_runs":"yes"}}', encoding="utf-8") + with self.assertRaisesRegex(PlanError, "package_completed_runs"): + load_config(root) def test_configuration_validates_bounded_network_and_packet_settings(self) -> None: """Collector-specific settings fail early rather than being silently coerced at runtime.""" From e8acc0ea76c1a41ce4e427eaa09bce9f82d04679 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:47:51 +0300 Subject: [PATCH 118/316] feat: verify packaged artifact integrity --- TODO.md | 10 +++---- logicytics/packaging.py | 37 +++++++++++++++++++------ tests/test_core_functionality.py | 47 +++++++++++++++++++++++++++++++- 3 files changed, 80 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index c9aaacd0..f81b9dc0 100644 --- a/TODO.md +++ b/TODO.md @@ -485,15 +485,15 @@ Windows platform allows. ### Improve packaging and evidence integrity -- [ ] Package only finalized artifacts referenced by the manifest. +- [x] Package only finalized artifacts referenced by the manifest. - [ ] Stream ZIP creation and hash calculation to support large runs. -- [ ] Add per-artifact SHA-256 values and a package-level SHA-256 value. -- [ ] Include a machine-readable manifest and a human-readable summary report. +- [x] Add per-artifact SHA-256 values and a package-level SHA-256 value. +- [x] Include a machine-readable manifest and a human-readable summary report. - [ ] Include collector status, skipped/failed reasons, and collection timestamps in the summary. - [ ] Preserve artifact provenance: collector ID, source category, collection time, and transformation steps. -- [ ] Verify package contents against the manifest before marking the run complete. +- [x] Verify package contents against the manifest before marking the run complete. - [x] Use atomic writes and temporary package names so interrupted packaging cannot look like a finished result. - [ ] Keep raw evidence, derived reports, logs, hashes, and metadata in separate @@ -514,7 +514,7 @@ Windows platform allows. duplicate collectors, and invalid configurations. - [ ] Add run tests for sequential, bounded-parallel, cancellation, timeout, partial-failure, rerun, and cleanup behavior. -- [ ] Add packaging tests proving that unregistered files cannot enter a package +- [x] Add packaging tests proving that unregistered files cannot enter a package and that manifest hashes match the final bytes. - [ ] Add golden-output tests for stable text, CSV, HTML, graph, and manifest formats, allowing platform-specific values to be normalized. diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 3bfe1b4b..f866d549 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -2,10 +2,11 @@ from __future__ import annotations +import hashlib import os import zipfile from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, BinaryIO from logicytics.artifacts import sha256_file from logicytics.manifest import RunManifest, write_manifest @@ -51,14 +52,29 @@ def _log_sources(run_directory: Path) -> list[tuple[Path, str]]: return sources -def _verify_archive(package_path: Path, expected_names: set[str]) -> None: - """Confirm an atomically written package contains exactly its declared inputs.""" +def _sha256_stream(stream: BinaryIO) -> str: + """Hash an open binary stream without loading the evidence into memory.""" + digest = hashlib.sha256() + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: set[str]) -> None: + """Confirm packaged artifact bytes match the finalized manifest exactly.""" with zipfile.ZipFile(package_path) as archive: - names = set(archive.namelist()) - if names != expected_names: + name_list = archive.namelist() + if len(name_list) != len(set(name_list)) or set(name_list) != expected_names: raise ValueError("package contents do not match its manifest-led input set") if archive.testzip() is not None: raise ValueError("package integrity verification failed") + for artifact in manifest.artifact_list(): + archive_name = f"artifacts/{artifact.relative_path}" + member = archive.getinfo(archive_name) + with archive.open(member) as stream: + digest = _sha256_stream(stream) + if member.file_size != artifact.size_bytes or digest != artifact.sha256: + raise ValueError(f"packaged artifact verification failed: {artifact.relative_path}") def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: Path) -> tuple[Path, Path]: @@ -84,15 +100,20 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: archive.write(summary_path, "summary.txt") for source, archive_name in (*artifact_sources, *log_sources): archive.write(source, archive_name) - _verify_archive(temporary_package, expected_names) + _verify_archive(temporary_package, manifest, expected_names) os.replace(temporary_package, package_path) finally: if temporary_package.exists(): temporary_package.unlink() temporary_hash = hash_path.with_suffix(".sha256.tmp") - temporary_hash.write_text(f"{sha256_file(package_path)} {package_path.name}\n", encoding="ascii") + package_sha256 = sha256_file(package_path) + temporary_hash.write_text(f"{package_sha256} {package_path.name}\n", encoding="ascii") os.replace(temporary_hash, hash_path) - manifest.package = {"path": str(package_path), "sha256_path": str(hash_path)} + manifest.package = { + "path": str(package_path), + "sha256_path": str(hash_path), + "sha256": package_sha256, + } write_manifest(manifest_path, manifest) return package_path, hash_path diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index f3051152..83ab47d1 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import tempfile import unittest import zipfile @@ -257,12 +258,56 @@ def test_preflight_plan_run_and_package(self) -> None: package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) - self.assertEqual(f"{package_path.name}", hash_path.read_text(encoding="ascii").split()[1]) + package_digest, sidecar_name = hash_path.read_text(encoding="ascii").split() + self.assertEqual(package_path.name, sidecar_name) + self.assertEqual(hashlib.sha256(package_path.read_bytes()).hexdigest(), package_digest) + self.assertEqual(package_digest, outcome.manifest.package["sha256"]) with zipfile.ZipFile(package_path) as archive: self.assertIsNone(archive.testzip()) self.assertIn("manifest.json", archive.namelist()) self.assertIn("summary.txt", archive.namelist()) self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("artifacts/")])) + artifact = outcome.manifest.artifact_list()[0] + archived_bytes = archive.read(f"artifacts/{artifact.relative_path}") + self.assertEqual(artifact.size_bytes, len(archived_bytes)) + self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) + + def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self) -> None: + """Only manifest artifacts may enter a package, and their final bytes must match.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + report = preflight(root) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + unregistered = outcome.run_directory / "artifacts" / "unregistered.txt" + unregistered.write_text("must not be packaged\n", encoding="utf-8") + + package_path, _ = package_run(outcome) + with zipfile.ZipFile(package_path) as archive: + self.assertNotIn("artifacts/unregistered.txt", archive.namelist()) + + original_write = zipfile.ZipFile.write + + def tampering_write( + archive: zipfile.ZipFile, + filename: str | Path, + arcname: str | None = None, + compress_type: int | None = None, + compresslevel: int | None = None, + ) -> None: + if arcname and arcname.startswith("artifacts/"): + archive.writestr(arcname, b"tampered artifact bytes") + return + original_write(archive, filename, arcname, compress_type, compresslevel) + + with patch.object(zipfile.ZipFile, "write", new=tampering_write): + with self.assertRaisesRegex(ValueError, "packaged artifact verification failed"): + package_run(outcome) + self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: """An isolated collector crash must produce a durable failed run outcome.""" From 9797bf36abe28743a6406d4b4adce9fdbab10ef1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:49:49 +0300 Subject: [PATCH 119/316] feat: enrich package run summaries --- TODO.md | 2 +- logicytics/packaging.py | 22 ++++++++++++++++++++-- tests/test_core_functionality.py | 24 ++++++++++++++++++++++-- 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index f81b9dc0..b8bfdbbe 100644 --- a/TODO.md +++ b/TODO.md @@ -489,7 +489,7 @@ Windows platform allows. - [ ] Stream ZIP creation and hash calculation to support large runs. - [x] Add per-artifact SHA-256 values and a package-level SHA-256 value. - [x] Include a machine-readable manifest and a human-readable summary report. -- [ ] Include collector status, skipped/failed reasons, and collection timestamps +- [x] Include collector status, skipped/failed reasons, and collection timestamps in the summary. - [ ] Preserve artifact provenance: collector ID, source category, collection time, and transformation steps. diff --git a/logicytics/packaging.py b/logicytics/packaging.py index f866d549..6a9dd83e 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -16,9 +16,27 @@ def _summary(manifest: RunManifest) -> str: - lines = [f"Logicytics run: {manifest.run_id}", f"Status: {manifest.status.value}", "", "Collectors:"] + lines = [ + f"Logicytics run: {manifest.run_id}", + f"Status: {manifest.status.value}", + f"Requested: {manifest.requested_at}", + f"Finished: {manifest.finished_at or 'not finalized'}", + "", + "Collectors:", + ] for record in manifest.collectors: - lines.append(f"- {record.id}: {record.status} — {record.summary or ''}".rstrip()) + lines.extend( + [ + f"- {record.id}", + f" Status: {record.status}", + f" Started: {record.started_at or 'not started'}", + f" Finished: {record.finished_at or 'not finished'}", + f" Summary: {record.summary or 'none'}", + ] + ) + if record.errors: + lines.append(" Reasons:") + lines.extend(f" - {' '.join(error.splitlines())}" for error in record.errors) lines.extend(["", f"Artifacts: {len(manifest.artifact_list())}"]) return "\n".join(lines) + "\n" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 83ab47d1..eac48ecc 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -271,6 +271,11 @@ def test_preflight_plan_run_and_package(self) -> None: archived_bytes = archive.read(f"artifacts/{artifact.relative_path}") self.assertEqual(artifact.size_bytes, len(archived_bytes)) self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) + record = outcome.manifest.collectors[0] + summary = archive.read("summary.txt").decode("utf-8") + self.assertIn(f"Status: {record.status}", summary) + self.assertIn(f"Started: {record.started_at}", summary) + self.assertIn(f"Finished: {record.finished_at}", summary) def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self) -> None: """Only manifest artifacts may enter a package, and their final bytes must match.""" @@ -334,7 +339,15 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N self.assertEqual("failed", record.status) self.assertTrue(any("RuntimeError" in error for error in record.errors)) self.assertIsNotNone(outcome.manifest.package) - self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) + package_path = Path(outcome.manifest.package["path"]) + self.assertTrue(package_path.is_file()) + with zipfile.ZipFile(package_path) as archive: + summary = archive.read("summary.txt").decode("utf-8") + self.assertIn("Status: failed", summary) + self.assertIn("Reasons:", summary) + self.assertIn("RuntimeError", summary) + self.assertIn(f"Started: {record.started_at}", summary) + self.assertIn(f"Finished: {record.finished_at}", summary) def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: """Keyboard cancellation must affect only this run and preserve its partial report.""" @@ -355,7 +368,14 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: self.assertEqual("run cancelled by user", record.summary) self.assertTrue((outcome.run_directory / ".cancelled").is_file()) self.assertIsNotNone(outcome.manifest.package) - self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) + package_path = Path(outcome.manifest.package["path"]) + self.assertTrue(package_path.is_file()) + with zipfile.ZipFile(package_path) as archive: + summary = archive.read("summary.txt").decode("utf-8") + self.assertIn("Status: cancelled", summary) + self.assertIn("Summary: run cancelled by user", summary) + self.assertIn("Started: not started", summary) + self.assertIn(f"Finished: {record.finished_at}", summary) def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: with tempfile.TemporaryDirectory() as temporary: From 023457788f9b3c4f0171b490c85e3da7affdde12 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:53:17 +0300 Subject: [PATCH 120/316] feat: preserve artifact provenance --- TODO.md | 2 +- logicytics/artifacts.py | 25 +++++++++++++- logicytics/contracts.py | 11 ++++++- logicytics/runtime.py | 8 +++++ logicytics/validation_worker.py | 8 ++++- tests/test_core_functionality.py | 40 +++++++++++++++++++++++ tests/test_running_processes_collector.py | 8 ++++- 7 files changed, 97 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index b8bfdbbe..f03f9b04 100644 --- a/TODO.md +++ b/TODO.md @@ -491,7 +491,7 @@ Windows platform allows. - [x] Include a machine-readable manifest and a human-readable summary report. - [x] Include collector status, skipped/failed reasons, and collection timestamps in the summary. -- [ ] Preserve artifact provenance: collector ID, source category, collection time, +- [x] Preserve artifact provenance: collector ID, source category, collection time, and transformation steps. - [x] Verify package contents against the manifest before marking the run complete. - [x] Use atomic writes and temporary package names so interrupted packaging cannot diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index ff854007..eb6308a9 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -4,6 +4,7 @@ import hashlib import shutil +from datetime import datetime, timezone from pathlib import Path from uuid import uuid4 @@ -38,8 +39,17 @@ def __init__( artifact_root: Path, maximum_output_bytes: int, maximum_artifact_files: int, + *, + source_category: str | None = None, ) -> None: self._collector_id = collector_id + collector_parts = collector_id.split(".", 2) + resolved_category = source_category if source_category is not None else ( + collector_parts[1] if len(collector_parts) > 1 else "" + ) + if not isinstance(resolved_category, str) or not resolved_category.strip(): + raise ArtifactError("artifact source_category must be a non-empty string") + self._source_category = resolved_category self._workspace = workspace.resolve() self._artifact_root = artifact_root.resolve() self._maximum_output_bytes = maximum_output_bytes @@ -52,7 +62,17 @@ def artifacts(self) -> tuple[Artifact, ...]: """Return artifacts registered by this worker in registration order.""" return tuple(self._artifacts) - def register_file(self, source: Path, *, media_type: str = "application/octet-stream") -> Artifact: + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + ) -> Artifact: + if not isinstance(transformations, tuple) or any( + not isinstance(step, str) or not step.strip() for step in transformations + ): + raise ArtifactError("artifact transformations must be a tuple of non-empty strings") source = source.resolve() if not source.is_file() or not _is_within(source, self._workspace): raise ArtifactError("artifacts must be regular files inside the collector workspace") @@ -76,6 +96,9 @@ def register_file(self, source: Path, *, media_type: str = "application/octet-st size_bytes=size_bytes, media_type=media_type, collector_id=self._collector_id, + source_category=self._source_category, + collected_at=datetime.now(timezone.utc).isoformat(), + transformations=(*transformations, "copied into run artifact store"), ) self._bytes_registered += size_bytes self._artifacts.append(artifact) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index ccba4718..8b9a3176 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -188,6 +188,9 @@ class Artifact: size_bytes: int media_type: str collector_id: str + source_category: str + collected_at: str + transformations: tuple[str, ...] def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -291,7 +294,13 @@ class ArtifactWriter(ABC): """The only supported route from a collector workspace into run artifacts.""" @abstractmethod - def register_file(self, source: Path, *, media_type: str = "application/octet-stream") -> Artifact: + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + ) -> Artifact: """Register a file created within the collector workspace.""" diff --git a/logicytics/runtime.py b/logicytics/runtime.py index b7a81d0a..fc74876d 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -71,6 +71,9 @@ def _artifact_from_dict(data: dict[str, object]) -> Artifact: size_bytes=int(data["size_bytes"]), media_type=str(data["media_type"]), collector_id=str(data["collector_id"]), + source_category=str(data["source_category"]), + collected_at=str(data["collected_at"]), + transformations=tuple(str(step) for step in data["transformations"]), ) @@ -103,6 +106,11 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu Path(str(payload["artifact_root"])), metadata.maximum_output_bytes, metadata.maximum_artifact_files, + source_category=( + metadata.specialty.value + if hasattr(metadata.specialty, "value") + else metadata.specialty + ), ) context = CollectorContext( run_id=str(payload["run_id"]), diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index 99e5d425..301a388d 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -30,7 +30,13 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: class _ProbeArtifactWriter: """Reject evidence registration during preflight validation.""" - def register_file(self, source: Path, *, media_type: str = "application/octet-stream"): + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + ): """Prevent a validation method from registering collection artifacts.""" raise RuntimeError("validate() must not register artifacts") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index eac48ecc..8d503072 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -3,9 +3,11 @@ from __future__ import annotations import hashlib +import json import tempfile import unittest import zipfile +from datetime import datetime from pathlib import Path from unittest.mock import patch @@ -228,6 +230,36 @@ def test_artifact_registration_enforces_collector_file_count_limit(self) -> None with self.assertRaisesRegex(ArtifactError, "maximum_artifact_files"): writer.register_file(second, media_type="text/plain") + def test_artifact_registration_records_validated_provenance(self) -> None: + """Artifact provenance is typed, immutable, and completed by the writer.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "report.json" + source.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ArtifactError, "source_category"): + WorkspaceArtifactWriter("invalid", workspace, artifact_root, 1024, 1) + writer = WorkspaceArtifactWriter( + "plugin.example", + workspace, + artifact_root, + 1024, + 1, + source_category="evidence_graph", + ) + with self.assertRaisesRegex(ArtifactError, "transformations"): + writer.register_file(source, transformations=["normalized"]) # type: ignore[arg-type] + artifact = writer.register_file(source, transformations=("normalized",)) + self.assertEqual("evidence_graph", artifact.source_category) + datetime.fromisoformat(artifact.collected_at) + self.assertEqual( + ("normalized", "copied into run artifact store"), + artifact.transformations, + ) + def test_preflight_plan_run_and_package(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -271,6 +303,14 @@ def test_preflight_plan_run_and_package(self) -> None: archived_bytes = archive.read(f"artifacts/{artifact.relative_path}") self.assertEqual(artifact.size_bytes, len(archived_bytes)) self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) + self.assertEqual("system", artifact.source_category) + datetime.fromisoformat(artifact.collected_at) + self.assertEqual(("copied into run artifact store",), artifact.transformations) + packaged_manifest = json.loads(archive.read("manifest.json")) + packaged_artifact = packaged_manifest["collectors"][0]["artifacts"][0] + self.assertEqual(artifact.source_category, packaged_artifact["source_category"]) + self.assertEqual(artifact.collected_at, packaged_artifact["collected_at"]) + self.assertEqual(list(artifact.transformations), packaged_artifact["transformations"]) record = outcome.manifest.collectors[0] summary = archive.read("summary.txt").decode("utf-8") self.assertIn(f"Status: {record.status}", summary) diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index 91bcacbd..613107a0 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -15,7 +15,13 @@ class _UnusedArtifactWriter(ArtifactWriter): """Raise if a skipped collector unexpectedly attempts artifact registration.""" - def register_file(self, source: Path, *, media_type: str = "application/octet-stream"): + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + ): raise AssertionError("a skipped collector must not register artifacts") From dff94541488a293db53c043a242503e1cd5200ed Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:55:17 +0300 Subject: [PATCH 121/316] test: prove deterministic parallel manifests --- TODO.md | 2 +- tests/test_core_functionality.py | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index f03f9b04..ccf16c01 100644 --- a/TODO.md +++ b/TODO.md @@ -447,7 +447,7 @@ Windows platform allows. registry-sensitive, and interactive collectors can be coordinated. - [ ] Prevent concurrent collectors from writing the same artifact path. - [ ] Use thread-safe artifact registration and structured event reporting. -- [ ] Preserve deterministic manifest ordering even when collectors finish out of +- [x] Preserve deterministic manifest ordering even when collectors finish out of order. - [ ] Define which collectors may run in parallel and which must run serially. - [ ] Provide a sequential mode for debugging and a bounded parallel mode for diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 8d503072..f7899005 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -354,6 +354,39 @@ def tampering_write( package_run(outcome) self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) + def test_parallel_completion_preserves_deterministic_manifest_order(self) -> None: + """Collector completion order must not reorder the preflighted execution plan.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + + def collector_source(filename: str, delay: float) -> str: + class_name = "".join(part.title() for part in filename.split("_")) + source = _COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") + source = source.replace("core.system.system_info", f"core.system.{filename}") + source = source.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n") + return source.replace( + ' output = context.workspace / "system.txt"', + f' sleep({delay})\n output = context.workspace / "system.txt"', + ) + + (core_directory / "a_slow.py").write_text(collector_source("a_slow", 0.6), encoding="utf-8") + (core_directory / "z_fast.py").write_text(collector_source("z_fast", 0.0), encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest(max_workers=2, acknowledge_authorization=True), + ) + planned_ids = [candidate.metadata.id for candidate in plan.collectors] + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) + self.assertLess(records["core.system.z_fast"].finished_at, records["core.system.a_slow"].finished_at) + def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: """An isolated collector crash must produce a durable failed run outcome.""" with tempfile.TemporaryDirectory() as temporary: From 60b2fe338bcef3e4a61678b6b889850cb2d9f5a1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 22:57:48 +0300 Subject: [PATCH 122/316] fix: honor collector parallel safety --- TODO.md | 2 +- logicytics/runtime.py | 11 +++++- tests/test_core_functionality.py | 68 ++++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/TODO.md b/TODO.md index ccf16c01..12f4474d 100644 --- a/TODO.md +++ b/TODO.md @@ -449,7 +449,7 @@ Windows platform allows. - [ ] Use thread-safe artifact registration and structured event reporting. - [x] Preserve deterministic manifest ordering even when collectors finish out of order. -- [ ] Define which collectors may run in parallel and which must run serially. +- [x] Define which collectors may run in parallel and which must run serially. - [ ] Provide a sequential mode for debugging and a bounded parallel mode for normal use; do not make parallelism a hidden behavior of a particular flag. diff --git a/logicytics/runtime.py b/logicytics/runtime.py index fc74876d..1935bab9 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -49,6 +49,7 @@ class _ActiveWorker: started_at: float timeout_seconds: int workspace: Path + parallel_safe: bool last_event_count: int = 0 last_heartbeat_at: float = 0.0 @@ -269,8 +270,13 @@ def _supervise( ) return while pending and len(active) < worker_limit: - candidate = pending.pop(0) + if any(not worker.parallel_safe for worker in active.values()): + break + candidate = pending[0] assert candidate.metadata is not None + if active and not candidate.metadata.parallel_safe: + break + pending.pop(0) workspace = workspace_root / candidate.metadata.id.replace(".", "_") payload: dict[str, object] = { "run_id": run_id, @@ -297,9 +303,12 @@ def _supervise( monotonic(), candidate.metadata.timeout_seconds, workspace, + candidate.metadata.parallel_safe, ) run_logger.event("info", "collector_started", collector_id=candidate.metadata.id) write_manifest(manifest_path, manifest) + if not candidate.metadata.parallel_safe: + break try: message = result_queue.get(timeout=0.1) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index f7899005..2223cceb 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -66,6 +66,22 @@ def cleanup(self, context: CollectorContext) -> None: ''' +def _delayed_collector_source(filename: str, delay: float, *, parallel_safe: bool = True) -> str: + """Create a valid fixture collector with observable scheduling duration.""" + class_name = "".join(part.title() for part in filename.split("_")) + source = _COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") + source = source.replace("core.system.system_info", f"core.system.{filename}") + source = source.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n") + source = source.replace( + ' supported_platforms=("win32",),', + f' supported_platforms=("win32",),\n parallel_safe={parallel_safe!r},', + ) + return source.replace( + ' output = context.workspace / "system.txt"', + f' sleep({delay})\n output = context.workspace / "system.txt"', + ) + + class CoreFunctionalityTests(unittest.TestCase): def test_sysinternals_archive_lifecycle_honors_ignore_and_extracts_safely(self) -> None: """The local bundle must honor opt-out and extract only within its target directory.""" @@ -362,18 +378,14 @@ def test_parallel_completion_preserves_deterministic_manifest_order(self) -> Non core_directory.mkdir(parents=True) (root / "plugins").mkdir() - def collector_source(filename: str, delay: float) -> str: - class_name = "".join(part.title() for part in filename.split("_")) - source = _COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") - source = source.replace("core.system.system_info", f"core.system.{filename}") - source = source.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n") - return source.replace( - ' output = context.workspace / "system.txt"', - f' sleep({delay})\n output = context.workspace / "system.txt"', - ) - - (core_directory / "a_slow.py").write_text(collector_source("a_slow", 0.6), encoding="utf-8") - (core_directory / "z_fast.py").write_text(collector_source("z_fast", 0.0), encoding="utf-8") + (core_directory / "a_slow.py").write_text( + _delayed_collector_source("a_slow", 0.6), + encoding="utf-8", + ) + (core_directory / "z_fast.py").write_text( + _delayed_collector_source("z_fast", 0.0), + encoding="utf-8", + ) report = preflight(root) self.assertEqual((), report.invalid) plan = build_plan( @@ -387,6 +399,38 @@ def collector_source(filename: str, delay: float) -> str: self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) self.assertLess(records["core.system.z_fast"].finished_at, records["core.system.a_slow"].finished_at) + def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: + """A collector declaring parallel_safe=False must run alone between bounded workers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + fixtures = ( + ("a_parallel", 0.3, True), + ("m_serial", 0.2, False), + ("z_parallel", 0.0, True), + ) + for filename, delay, parallel_safe in fixtures: + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, delay, parallel_safe=parallel_safe), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest(max_workers=3, acknowledge_authorization=True), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + first = records["core.system.a_parallel"] + serial = records["core.system.m_serial"] + last = records["core.system.z_parallel"] + self.assertLessEqual(first.finished_at, serial.started_at) + self.assertLessEqual(serial.finished_at, last.started_at) + def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: """An isolated collector crash must produce a durable failed run outcome.""" with tempfile.TemporaryDirectory() as temporary: From bf4c887d1e37bb6a94adcb76da20b0481bd260cf Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 24 Aug 2026 23:01:42 +0300 Subject: [PATCH 123/316] fix: enforce collector dependencies at runtime --- logicytics/runtime.py | 37 ++++++++++++++ tests/test_core_functionality.py | 86 ++++++++++++++++++++++++++++++-- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 1935bab9..3cf380f8 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -52,6 +52,7 @@ class _ActiveWorker: parallel_safe: bool last_event_count: int = 0 last_heartbeat_at: float = 0.0 + exited_at: float | None = None def _load_collector(path: Path, expected_class: str): @@ -274,6 +275,37 @@ def _supervise( break candidate = pending[0] assert candidate.metadata is not None + dependency_states = { + dependency: records[dependency].status + for dependency in candidate.metadata.dependencies + } + failed_dependencies = { + dependency: status + for dependency, status in dependency_states.items() + if status in {"partial", "skipped", "cancelled", "failed"} + } + if failed_dependencies: + pending.pop(0) + details = ", ".join( + f"{dependency}={status}" + for dependency, status in sorted(failed_dependencies.items()) + ) + records[candidate.metadata.id].apply_result( + CollectorResult( + CollectorStatus.SKIPPED, + "collector dependency was not satisfied", + errors=(f"dependency did not succeed: {details}",), + ) + ) + run_logger.event( + "warning", + "collector_dependency_unsatisfied", + collector_id=candidate.metadata.id, + ) + write_manifest(manifest_path, manifest) + continue + if any(status != "succeeded" for status in dependency_states.values()): + break if active and not candidate.metadata.parallel_safe: break pending.pop(0) @@ -340,6 +372,11 @@ def _supervise( write_manifest(manifest_path, manifest) elif not worker.process.is_alive(): worker.process.join(timeout=1) + if worker.exited_at is None: + worker.exited_at = monotonic() + continue + if monotonic() - worker.exited_at < 1.0: + continue active.pop(collector_id) self._apply_worker_result( records[collector_id], diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 2223cceb..61720ba8 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -66,7 +66,14 @@ def cleanup(self, context: CollectorContext) -> None: ''' -def _delayed_collector_source(filename: str, delay: float, *, parallel_safe: bool = True) -> str: +def _delayed_collector_source( + filename: str, + delay: float, + *, + parallel_safe: bool = True, + dependencies: tuple[str, ...] = (), + fail: bool = False, +) -> str: """Create a valid fixture collector with observable scheduling duration.""" class_name = "".join(part.title() for part in filename.split("_")) source = _COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") @@ -74,12 +81,28 @@ def _delayed_collector_source(filename: str, delay: float, *, parallel_safe: boo source = source.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n") source = source.replace( ' supported_platforms=("win32",),', - f' supported_platforms=("win32",),\n parallel_safe={parallel_safe!r},', + ' supported_platforms=("win32",),\n' + f' dependencies={dependencies!r},\n' + f' parallel_safe={parallel_safe!r},', ) - return source.replace( + source = source.replace( + " def validate(self, context: CollectorContext) -> ValidationResult:\n", + " @classmethod\n" + " def dependencies(cls) -> tuple[str, ...]:\n" + " \"\"\"Return fixture dependencies for scheduler tests.\"\"\"\n" + f" return {dependencies!r}\n\n" + " def validate(self, context: CollectorContext) -> ValidationResult:\n", + ) + source = source.replace( ' output = context.workspace / "system.txt"', f' sleep({delay})\n output = context.workspace / "system.txt"', ) + if fail: + source = source.replace( + f' sleep({delay})\n output = context.workspace / "system.txt"', + f' sleep({delay})\n raise RuntimeError("dependency fixture failed")', + ) + return source class CoreFunctionalityTests(unittest.TestCase): @@ -431,6 +454,63 @@ def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: self.assertLessEqual(first.finished_at, serial.started_at) self.assertLessEqual(serial.finished_at, last.started_at) + def test_dependencies_finish_before_dependents_start(self) -> None: + """Topological order must become an execution barrier under bounded parallelism.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + (core_directory / "a_dependency.py").write_text( + _delayed_collector_source("a_dependency", 0.3), + encoding="utf-8", + ) + (core_directory / "b_dependent.py").write_text( + _delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + dependency = records[dependency_id] + dependent = records["core.system.b_dependent"] + self.assertEqual("succeeded", dependent.status, dependent.errors) + self.assertIsNotNone(dependency.finished_at) + self.assertIsNotNone(dependent.started_at) + self.assertLessEqual(dependency.finished_at, dependent.started_at) + + def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: + """A failed prerequisite must contain failure and prevent dependent execution.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + (core_directory / "a_dependency.py").write_text( + _delayed_collector_source("a_dependency", 0.0, fail=True), + encoding="utf-8", + ) + (core_directory / "b_dependent.py").write_text( + _delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("failed", records[dependency_id].status) + dependent = records["core.system.b_dependent"] + self.assertEqual("skipped", dependent.status) + self.assertIsNone(dependent.started_at) + self.assertTrue(any(dependency_id in error for error in dependent.errors)) + def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: """An isolated collector crash must produce a durable failed run outcome.""" with tempfile.TemporaryDirectory() as temporary: From 9d503811ec0f8e65ebf1230d12310e00fe9b715b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 12:46:35 +0300 Subject: [PATCH 124/316] fix: validate run requests and worker configuration --- TODO.md | 2 +- logicytics/configuration.py | 13 +++++-- logicytics/contracts.py | 27 ++++++++++++++ tests/test_core_functionality.py | 63 ++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index 12f4474d..80b5ed12 100644 --- a/TODO.md +++ b/TODO.md @@ -574,7 +574,7 @@ Windows platform allows. - [ ] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. - [ ] Exclude engine/library directories and underscore-prefixed helper files from automatic collector discovery. -- [ ] Support a configurable worker limit for parallel collection. +- [x] Support a configurable worker limit for parallel collection. - [ ] Provide per-collector success, failure, and duration information. ### Execution modes diff --git a/logicytics/configuration.py b/logicytics/configuration.py index c35149ca..2e03c214 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -89,7 +89,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi if not isinstance(raw, dict): raise PlanError("configuration root must be a JSON object") schema_version = raw.get("schema_version", SCHEMA_VERSION) - if not isinstance(schema_version, int): + if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise PlanError("configuration schema_version must be an integer") if schema_version != SCHEMA_VERSION: raise PlanError(f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION}") @@ -97,12 +97,19 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi runtime_raw = raw.get("runtime", {}) if not isinstance(runtime_raw, dict): raise PlanError("runtime configuration must be an object") - output_root = Path(runtime_raw.get("output_root", project_root / "ACCESS" / "RUNS")) + output_root_value = runtime_raw.get("output_root", project_root / "ACCESS" / "RUNS") + if not isinstance(output_root_value, (str, Path)) or not str(output_root_value).strip(): + raise PlanError("runtime output_root must be a non-empty path string") + output_root = Path(output_root_value) if not output_root.is_absolute(): output_root = project_root / output_root default_workers = runtime_raw.get("default_max_workers", 4) maximum_workers = runtime_raw.get("maximum_workers", 16) - if not isinstance(default_workers, int) or not isinstance(maximum_workers, int): + if not _positive_integer(default_workers, minimum=1, maximum=64) or not _positive_integer( + maximum_workers, + minimum=1, + maximum=64, + ): raise PlanError("worker limits must be integers") if not 1 <= default_workers <= maximum_workers <= 64: raise PlanError("worker limits must satisfy 1 <= default <= maximum <= 64") diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 8b9a3176..541ace53 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -248,6 +248,33 @@ class RunRequest: acknowledge_authorization: bool = False approved_capabilities: tuple[Capability, ...] = () + def __post_init__(self) -> None: + """Reject malformed selections and execution policy before planning.""" + if not isinstance(self.profile, str) or not _LABEL.fullmatch(self.profile): + raise ValueError("request profile must be a lowercase profile label") + for name in ("include", "exclude"): + selections = getattr(self, name) + if not isinstance(selections, tuple) or not all( + isinstance(collector_id, str) and _COLLECTOR_ID.fullmatch(collector_id) + for collector_id in selections + ): + raise ValueError(f"request {name} must be a tuple of collector IDs") + if len(set(selections)) != len(selections): + raise ValueError(f"request {name} must not contain duplicate collector IDs") + if set(self.include).intersection(self.exclude): + raise ValueError("request include and exclude selections must not overlap") + for name in ("enable_plugins", "acknowledge_authorization"): + if not isinstance(getattr(self, name), bool): + raise ValueError(f"request {name} must be boolean") + if not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or not 1 <= self.max_workers <= 64: + raise ValueError("request max_workers must be an integer from 1 to 64") + if not isinstance(self.approved_capabilities, tuple) or not all( + isinstance(capability, Capability) for capability in self.approved_capabilities + ): + raise ValueError("request approved_capabilities must be a tuple of Capability values") + if len(set(self.approved_capabilities)) != len(self.approved_capabilities): + raise ValueError("request approved_capabilities must not contain duplicates") + class EventLogger(ABC): """A structured event sink scoped to one run or collector worker.""" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 61720ba8..a94fa442 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -211,6 +211,69 @@ def test_configuration_schema_version_is_enforced(self) -> None: with self.assertRaisesRegex(PlanError, "package_completed_runs"): load_config(root) + def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> None: + """Runtime worker limits and output locations must retain strict JSON types.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid_configurations = ( + ('{"schema_version":true}', "schema_version"), + ('{"schema_version":4,"runtime":{"default_max_workers":true}}', "worker limits"), + ('{"schema_version":4,"runtime":{"maximum_workers":true}}', "worker limits"), + ('{"schema_version":4,"runtime":{"output_root":false}}', "output_root"), + ('{"schema_version":4,"runtime":{"output_root":" "}}', "output_root"), + ) + for payload, message in invalid_configurations: + with self.subTest(payload=payload): + config_path.write_text(payload, encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + load_config(root) + + def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> None: + """Run requests must be immutable, typed declarations before a plan exists.""" + collector_id = "core.system.system_info" + invalid_requests = ( + ({"profile": "Standard"}, "profile"), + ({"include": [collector_id]}, "include"), + ({"include": ("../system",)}, "include"), + ({"include": (collector_id, collector_id)}, "include"), + ({"include": (collector_id,), "exclude": (collector_id,)}, "overlap"), + ({"enable_plugins": 1}, "enable_plugins"), + ({"acknowledge_authorization": 1}, "acknowledge_authorization"), + ({"max_workers": True}, "max_workers"), + ({"max_workers": 65}, "max_workers"), + ({"approved_capabilities": ("filesystem_read",)}, "approved_capabilities"), + ( + {"approved_capabilities": (Capability.FILESYSTEM_READ, Capability.FILESYSTEM_READ)}, + "approved_capabilities", + ), + ) + for options, message in invalid_requests: + with self.subTest(options=options): + with self.assertRaisesRegex(ValueError, message): + RunRequest(**options) + + def test_configured_worker_limit_blocks_oversized_run_before_workspace_creation(self) -> None: + """The configured maximum worker count bounds a run before any collector starts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', + encoding="utf-8", + ) + configuration = load_config(root) + self.assertEqual(1, configuration.runtime.default_max_workers) + self.assertEqual(2, configuration.runtime.maximum_workers) + plan = build_plan(preflight(root), RunRequest(max_workers=3, acknowledge_authorization=True)) + with self.assertRaisesRegex(ValueError, "maximum_workers"): + RunSupervisor(root, configuration).run(plan) + self.assertFalse(configuration.runtime.output_root.exists()) + def test_configuration_validates_bounded_network_and_packet_settings(self) -> None: """Collector-specific settings fail early rather than being silently coerced at runtime.""" with tempfile.TemporaryDirectory() as temporary: From efc81def2b7949f746d2b1ae2860797e0b93f529 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:21:37 +0300 Subject: [PATCH 125/316] feat: enforce individual artifact size limits --- logicytics/artifacts.py | 11 +++++++ logicytics/contracts.py | 12 +++++++- logicytics/runtime.py | 1 + tests/test_core_functionality.py | 53 ++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index eb6308a9..4c012d8f 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -41,6 +41,7 @@ def __init__( maximum_artifact_files: int, *, source_category: str | None = None, + maximum_artifact_bytes: int | None = None, ) -> None: self._collector_id = collector_id collector_parts = collector_id.split(".", 2) @@ -53,6 +54,14 @@ def __init__( self._workspace = workspace.resolve() self._artifact_root = artifact_root.resolve() self._maximum_output_bytes = maximum_output_bytes + self._maximum_artifact_bytes = ( + maximum_output_bytes if maximum_artifact_bytes is None else maximum_artifact_bytes + ) + if not isinstance(self._maximum_artifact_bytes, int) or isinstance( + self._maximum_artifact_bytes, + bool, + ) or not 1 <= self._maximum_artifact_bytes <= maximum_output_bytes: + raise ArtifactError("maximum_artifact_bytes must be a positive integer within maximum_output_bytes") self._maximum_artifact_files = maximum_artifact_files self._bytes_registered = 0 self._artifacts: list[Artifact] = [] @@ -77,6 +86,8 @@ def register_file( if not source.is_file() or not _is_within(source, self._workspace): raise ArtifactError("artifacts must be regular files inside the collector workspace") size_bytes = source.stat().st_size + if size_bytes > self._maximum_artifact_bytes: + raise ArtifactError("collector artifact exceeds its declared maximum_artifact_bytes") if self._bytes_registered + size_bytes > self._maximum_output_bytes: raise ArtifactError("collector output exceeds its declared maximum_output_bytes") if len(self._artifacts) >= self._maximum_artifact_files: diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 541ace53..baf9a140 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -101,6 +101,7 @@ class CollectorMetadata: default_profiles: tuple[str, ...] = ("standard",) timeout_seconds: int = 60 maximum_output_bytes: int = 100 * 1024 * 1024 + maximum_artifact_bytes: int | None = None maximum_artifact_files: int = 500 minimum_contract_version: str = CONTRACT_VERSION parallel_safe: bool = True @@ -132,10 +133,19 @@ def __post_init__(self) -> None: raise ValueError("metadata dependencies must be collector IDs") if len(set(self.dependencies)) != len(self.dependencies) or self.id in self.dependencies: raise ValueError("metadata dependencies must be unique and cannot include the collector itself") - for name in ("timeout_seconds", "maximum_output_bytes", "maximum_artifact_files"): + if self.maximum_artifact_bytes is None: + object.__setattr__(self, "maximum_artifact_bytes", self.maximum_output_bytes) + for name in ( + "timeout_seconds", + "maximum_output_bytes", + "maximum_artifact_bytes", + "maximum_artifact_files", + ): value = getattr(self, name) if not isinstance(value, int) or isinstance(value, bool) or value < 1: raise ValueError(f"metadata {name} must be a positive integer") + if self.maximum_artifact_bytes > self.maximum_output_bytes: + raise ValueError("metadata maximum_artifact_bytes must not exceed maximum_output_bytes") if not isinstance(self.parallel_safe, bool): raise ValueError("metadata parallel_safe must be boolean") diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 3cf380f8..7cc991ab 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -113,6 +113,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu if hasattr(metadata.specialty, "value") else metadata.specialty ), + maximum_artifact_bytes=metadata.maximum_artifact_bytes, ) context = CollectorContext( run_id=str(payload["run_id"]), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a94fa442..b0af5c61 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -142,6 +142,12 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "version": "four"}) with self.assertRaisesRegex(ValueError, "maximum_artifact_files"): CollectorMetadata(**{**common, "maximum_artifact_files": 0}) + with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): + CollectorMetadata(**{**common, "maximum_artifact_bytes": True}) + with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): + CollectorMetadata(**{**common, "maximum_output_bytes": 8, "maximum_artifact_bytes": 9}) + metadata = CollectorMetadata(**{**common, "maximum_output_bytes": 8}) + self.assertEqual(8, metadata.maximum_artifact_bytes) def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" @@ -332,6 +338,53 @@ def test_artifact_registration_enforces_collector_file_count_limit(self) -> None with self.assertRaisesRegex(ArtifactError, "maximum_artifact_files"): writer.register_file(second, media_type="text/plain") + def test_artifact_registration_enforces_individual_file_size_limit(self) -> None: + """A collector-specific file ceiling rejects large evidence before it is copied.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + oversized = workspace / "oversized.bin" + oversized.write_bytes(b"12345") + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 1024, + 5, + maximum_artifact_bytes=4, + ) + with self.assertRaisesRegex(ArtifactError, "maximum_artifact_bytes"): + writer.register_file(oversized) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list(artifact_root.rglob("*"))) + + def test_isolated_worker_enforces_declared_individual_artifact_limit(self) -> None: + """Metadata file limits must survive preflight and reach the isolated worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_artifact_bytes=2,', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + + record = outcome.manifest.collectors[0] + self.assertEqual("failed", record.status) + self.assertTrue(any("maximum_artifact_bytes" in error for error in record.errors)) + self.assertEqual([], record.artifacts) + def test_artifact_registration_records_validated_provenance(self) -> None: """Artifact provenance is typed, immutable, and completed by the writer.""" with tempfile.TemporaryDirectory() as temporary: From 0960e30364876de05d60d927110232868206665e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:25:19 +0300 Subject: [PATCH 126/316] feat: enforce run-wide evidence size limits --- logicytics/artifacts.py | 12 +++++ logicytics/configuration.py | 14 ++++++ logicytics/runtime.py | 41 +++++++++++++++ tests/test_core_functionality.py | 86 ++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index 4c012d8f..c26fa12b 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -42,6 +42,7 @@ def __init__( *, source_category: str | None = None, maximum_artifact_bytes: int | None = None, + run_output_budget_bytes: int | None = None, ) -> None: self._collector_id = collector_id collector_parts = collector_id.split(".", 2) @@ -63,6 +64,13 @@ def __init__( ) or not 1 <= self._maximum_artifact_bytes <= maximum_output_bytes: raise ArtifactError("maximum_artifact_bytes must be a positive integer within maximum_output_bytes") self._maximum_artifact_files = maximum_artifact_files + self._run_output_budget_bytes = run_output_budget_bytes + if run_output_budget_bytes is not None and ( + not isinstance(run_output_budget_bytes, int) + or isinstance(run_output_budget_bytes, bool) + or run_output_budget_bytes < 1 + ): + raise ArtifactError("run_output_budget_bytes must be a positive integer") self._bytes_registered = 0 self._artifacts: list[Artifact] = [] @@ -90,6 +98,10 @@ def register_file( raise ArtifactError("collector artifact exceeds its declared maximum_artifact_bytes") if self._bytes_registered + size_bytes > self._maximum_output_bytes: raise ArtifactError("collector output exceeds its declared maximum_output_bytes") + if self._run_output_budget_bytes is not None and ( + self._bytes_registered + size_bytes > self._run_output_budget_bytes + ): + raise ArtifactError("run output exceeds configured maximum_run_output_bytes") if len(self._artifacts) >= self._maximum_artifact_files: raise ArtifactError("collector artifact count exceeds its declared maximum_artifact_files") diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 2e03c214..2cf1d850 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -10,6 +10,8 @@ from logicytics.errors import PlanError SCHEMA_VERSION = 4 +DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 +MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 def _positive_integer(value: object, *, minimum: int, maximum: int) -> bool: @@ -50,6 +52,7 @@ class RuntimeSettings: default_max_workers: int = 4 maximum_workers: int = 16 package_completed_runs: bool = True + maximum_run_output_bytes: int = DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES @dataclass(frozen=True, slots=True) @@ -116,6 +119,16 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi package_completed_runs = runtime_raw.get("package_completed_runs", True) if not isinstance(package_completed_runs, bool): raise PlanError("runtime package_completed_runs must be boolean") + maximum_run_output_bytes = runtime_raw.get( + "maximum_run_output_bytes", + DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES, + ) + if not _positive_integer( + maximum_run_output_bytes, + minimum=1, + maximum=MAXIMUM_RUN_OUTPUT_BYTES, + ): + raise PlanError("runtime maximum_run_output_bytes must be an integer from 1 to 68719476736") collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( @@ -131,6 +144,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi default_max_workers=default_workers, maximum_workers=maximum_workers, package_completed_runs=package_completed_runs, + maximum_run_output_bytes=maximum_run_output_bytes, ), collector_settings=collector_settings, ) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 7cc991ab..1e6e54c6 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -50,6 +50,7 @@ class _ActiveWorker: timeout_seconds: int workspace: Path parallel_safe: bool + reserved_output_bytes: int last_event_count: int = 0 last_heartbeat_at: float = 0.0 exited_at: float | None = None @@ -114,6 +115,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu else metadata.specialty ), maximum_artifact_bytes=metadata.maximum_artifact_bytes, + run_output_budget_bytes=int(payload["run_output_budget_bytes"]), ) context = CollectorContext( run_id=str(payload["run_id"]), @@ -262,6 +264,7 @@ def _supervise( self._active_workers = active result_queue: multiprocessing.Queue = multiprocessing.get_context("spawn").Queue() worker_limit = min(plan.request.max_workers, self.configuration.runtime.maximum_workers) + committed_output_bytes = 0 while pending or active: if cancellation_file.exists(): self._cancel_active(active, records, manifest, manifest_path, "run cancellation requested") @@ -309,6 +312,31 @@ def _supervise( break if active and not candidate.metadata.parallel_safe: break + reserved_output_bytes = sum(worker.reserved_output_bytes for worker in active.values()) + remaining_output_bytes = ( + self.configuration.runtime.maximum_run_output_bytes + - committed_output_bytes + - reserved_output_bytes + ) + if remaining_output_bytes < 1: + if active: + break + pending.pop(0) + records[candidate.metadata.id].apply_result( + CollectorResult( + CollectorStatus.SKIPPED, + "run output limit reached before collector could start", + errors=("configured maximum_run_output_bytes was exhausted",), + ) + ) + run_logger.event( + "warning", + "collector_run_output_limit_reached", + collector_id=candidate.metadata.id, + ) + write_manifest(manifest_path, manifest) + continue + output_budget = min(candidate.metadata.maximum_output_bytes, remaining_output_bytes) pending.pop(0) workspace = workspace_root / candidate.metadata.id.replace(".", "_") payload: dict[str, object] = { @@ -320,6 +348,7 @@ def _supervise( "artifact_root": str(artifact_root), "cancellation_file": str(cancellation_file), "settings": dict(self.configuration.settings_for(candidate.metadata.id)), + "run_output_budget_bytes": output_budget, } process = multiprocessing.get_context("spawn").Process( target=_worker_entry, @@ -337,6 +366,7 @@ def _supervise( candidate.metadata.timeout_seconds, workspace, candidate.metadata.parallel_safe, + output_budget, ) run_logger.event("info", "collector_started", collector_id=candidate.metadata.id) write_manifest(manifest_path, manifest) @@ -353,6 +383,7 @@ def _supervise( if worker is not None: worker.process.join(timeout=1) self._apply_worker_result(records[collector_id], _result_from_dict(message["result"]), worker) + committed_output_bytes += self._collector_artifact_bytes(artifact_root, collector_id) self._cleanup_worker_temporary_directory(worker) run_logger.event("info", "collector_finished", collector_id=collector_id) write_manifest(manifest_path, manifest) @@ -368,6 +399,7 @@ def _supervise( CollectorResult(CollectorStatus.FAILED, "collector exceeded its declared timeout"), worker, ) + committed_output_bytes += self._collector_artifact_bytes(artifact_root, collector_id) self._cleanup_worker_temporary_directory(worker) run_logger.event("error", "collector_timed_out", collector_id=collector_id) write_manifest(manifest_path, manifest) @@ -384,11 +416,20 @@ def _supervise( CollectorResult(CollectorStatus.FAILED, "collector exited without a result"), worker, ) + committed_output_bytes += self._collector_artifact_bytes(artifact_root, collector_id) self._cleanup_worker_temporary_directory(worker) run_logger.event("error", "collector_exited_without_result", collector_id=collector_id) write_manifest(manifest_path, manifest) sleep(0.01) + @staticmethod + def _collector_artifact_bytes(artifact_root: Path, collector_id: str) -> int: + """Count run-owned evidence, including files left by a failed collector.""" + directory = artifact_root / collector_id.replace(".", "_") + if not directory.is_dir(): + return 0 + return sum(path.stat().st_size for path in directory.rglob("*") if path.is_file()) + @staticmethod def _cleanup_worker_temporary_directory(worker: _ActiveWorker) -> None: """Remove only a terminal worker's private scratch directory, never its logs.""" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index b0af5c61..6b2d626c 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -226,6 +226,8 @@ def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> ('{"schema_version":true}', "schema_version"), ('{"schema_version":4,"runtime":{"default_max_workers":true}}', "worker limits"), ('{"schema_version":4,"runtime":{"maximum_workers":true}}', "worker limits"), + ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":true}}', "maximum_run_output_bytes"), + ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":0}}', "maximum_run_output_bytes"), ('{"schema_version":4,"runtime":{"output_root":false}}', "output_root"), ('{"schema_version":4,"runtime":{"output_root":" "}}', "output_root"), ) @@ -361,6 +363,90 @@ def test_artifact_registration_enforces_individual_file_size_limit(self) -> None self.assertEqual((), writer.artifacts) self.assertEqual([], list(artifact_root.rglob("*"))) + def test_artifact_registration_enforces_reserved_run_output_budget(self) -> None: + """A worker cannot copy evidence beyond its supervisor-reserved run quota.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "oversized.bin" + source.write_bytes(b"12345") + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 1024, + 5, + run_output_budget_bytes=4, + ) + with self.assertRaisesRegex(ArtifactError, "maximum_run_output_bytes"): + writer.register_file(source) + self.assertEqual([], list(artifact_root.rglob("*"))) + + def test_run_output_budget_bounds_concurrent_collector_evidence(self) -> None: + """Concurrent isolated workers must never exceed the configured run-wide cap.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "b_second"): + source = _delayed_collector_source(filename, 0.1).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_output_bytes=4,', + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"runtime":{"maximum_run_output_bytes":7}}', + encoding="utf-8", + ) + configuration = load_config(root) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + + records = {record.id: record for record in outcome.manifest.collectors} + self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) + self.assertEqual("failed", records["core.system.b_second"].status) + self.assertTrue( + any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors) + ) + self.assertLessEqual(outcome.manifest.total_artifact_bytes, 7) + + def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: + """A completely consumed run quota prevents later workers from launching.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "b_second"): + source = _delayed_collector_source(filename, 0.0).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_output_bytes=4,', + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"runtime":{"maximum_run_output_bytes":4}}', + encoding="utf-8", + ) + configuration = load_config(root) + plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) + second = records["core.system.b_second"] + self.assertEqual("skipped", second.status) + self.assertIsNone(second.started_at) + self.assertTrue(any("maximum_run_output_bytes" in error for error in second.errors)) + self.assertEqual(4, outcome.manifest.total_artifact_bytes) + def test_isolated_worker_enforces_declared_individual_artifact_limit(self) -> None: """Metadata file limits must survive preflight and reach the isolated worker.""" with tempfile.TemporaryDirectory() as temporary: From 55c9ae8e562f51873dc6e90e9b1767683ecfbdb1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:28:44 +0300 Subject: [PATCH 127/316] fix: package performance reports with completed runs --- logicytics/cli.py | 15 +------------ logicytics/contracts.py | 5 ++++- logicytics/packaging.py | 9 ++++++-- logicytics/runtime.py | 19 ++++++++++++++++ tests/test_core_functionality.py | 38 +++++++++++++++++++++++++++++++- 5 files changed, 68 insertions(+), 18 deletions(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index 196b1233..c6fb4673 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -38,6 +38,7 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: False) else arguments.workers or default_workers, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), + performance_check=getattr(arguments, "performance_check", False), ) @@ -142,20 +143,6 @@ def main(argv: list[str] | None = None) -> int: outcome = RunSupervisor(root, configuration).run(plan) if arguments.performance_check: performance_path = outcome.run_directory / "logs" / "performance.json" - performance_path.write_text( - json.dumps( - { - "run_id": outcome.manifest.run_id, - "collectors": [ - {"id": record.id, "status": record.status, "duration_seconds": record.duration_seconds} - for record in outcome.manifest.collectors - ], - }, - indent=2, - sort_keys=True, - ) + "\n", - encoding="utf-8", - ) print(f"Performance: {performance_path}") if outcome.manifest.package and "path" in outcome.manifest.package: print( diff --git a/logicytics/contracts.py b/logicytics/contracts.py index baf9a140..34db3652 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -257,6 +257,7 @@ class RunRequest: max_workers: int = 4 acknowledge_authorization: bool = False approved_capabilities: tuple[Capability, ...] = () + performance_check: bool = False def __post_init__(self) -> None: """Reject malformed selections and execution policy before planning.""" @@ -273,11 +274,13 @@ def __post_init__(self) -> None: raise ValueError(f"request {name} must not contain duplicate collector IDs") if set(self.include).intersection(self.exclude): raise ValueError("request include and exclude selections must not overlap") - for name in ("enable_plugins", "acknowledge_authorization"): + for name in ("enable_plugins", "acknowledge_authorization", "performance_check"): if not isinstance(getattr(self, name), bool): raise ValueError(f"request {name} must be boolean") if not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or not 1 <= self.max_workers <= 64: raise ValueError("request max_workers must be an integer from 1 to 64") + if self.performance_check and self.max_workers != 1: + raise ValueError("request performance_check requires max_workers=1") if not isinstance(self.approved_capabilities, tuple) or not all( isinstance(capability, Capability) for capability in self.approved_capabilities ): diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 6a9dd83e..2cc03f97 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -59,7 +59,7 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ return sources -def _log_sources(run_directory: Path) -> list[tuple[Path, str]]: +def _log_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[Path, str]]: """Return only run-owned structured logs, preserving their relative paths.""" sources: list[tuple[Path, str]] = [] for directory in (run_directory / "logs", run_directory / "collectors"): @@ -67,6 +67,11 @@ def _log_sources(run_directory: Path) -> list[tuple[Path, str]]: continue for path in sorted(directory.rglob("*.jsonl")): sources.append((path, path.relative_to(run_directory).as_posix())) + if manifest.request.get("performance_check") is True: + performance_path = run_directory / "logs" / "performance.json" + if not performance_path.is_file(): + raise FileNotFoundError("requested performance report is missing") + sources.append((performance_path, "logs/performance.json")) return sources @@ -104,7 +109,7 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: summary_path = run_directory / "summary.txt" artifact_sources = _artifact_sources(run_directory, manifest) - log_sources = _log_sources(run_directory) + log_sources = _log_sources(run_directory, manifest) manifest.package = {"path": str(package_path)} write_manifest(manifest_path, manifest) summary_path.write_text(_summary(manifest), encoding="utf-8") diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 1e6e54c6..ec2f768a 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -229,6 +229,8 @@ def run(self, plan: RunPlan) -> RunOutcome: self._cancel_records(records, manifest, manifest_path, "run cancelled by user") manifest.finalize_status() + if plan.request.performance_check: + self._write_performance_report(run_directory, manifest) if self.configuration.runtime.package_completed_runs: try: package_path, hash_path = package_manifest(run_directory, manifest, manifest_path) @@ -246,6 +248,23 @@ def run(self, plan: RunPlan) -> RunOutcome: run_logger.event("info", "run_finished", status=manifest.status.value, artifacts=manifest.total_artifact_bytes) return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) + @staticmethod + def _write_performance_report(run_directory: Path, manifest: RunManifest) -> None: + """Finalize run-owned timing diagnostics before evidence packaging begins.""" + performance_path = run_directory / "logs" / "performance.json" + payload = { + "run_id": manifest.run_id, + "collectors": [ + { + "id": record.id, + "status": record.status, + "duration_seconds": record.duration_seconds, + } + for record in manifest.collectors + ], + } + performance_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + def _supervise( self, plan: RunPlan, diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 6b2d626c..a1e61afd 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -13,7 +13,7 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command -from logicytics.cli import _parser +from logicytics.cli import _parser, _request from logicytics.file_listing import list_files from logicytics.logging import deprecated, raise_logged, timed from logicytics.sysinternals import ensure_sysinternals @@ -248,6 +248,8 @@ def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> Non ({"include": (collector_id,), "exclude": (collector_id,)}, "overlap"), ({"enable_plugins": 1}, "enable_plugins"), ({"acknowledge_authorization": 1}, "acknowledge_authorization"), + ({"performance_check": 1}, "performance_check"), + ({"performance_check": True, "max_workers": 2}, "performance_check"), ({"max_workers": True}, "max_workers"), ({"max_workers": 65}, "max_workers"), ({"approved_capabilities": ("filesystem_read",)}, "approved_capabilities"), @@ -307,6 +309,9 @@ def test_run_parser_accepts_performance_check(self) -> None: """The run command must expose the performance mode used by the request builder.""" arguments = _parser().parse_args(["run", "--performance-check"]) self.assertTrue(arguments.performance_check) + request = _request(arguments, default_workers=4) + self.assertTrue(request.performance_check) + self.assertEqual(1, request.max_workers) """Validate the foundation before real core collectors are added.""" @@ -539,6 +544,7 @@ def test_preflight_plan_run_and_package(self) -> None: self.assertIsNone(archive.testzip()) self.assertIn("manifest.json", archive.namelist()) self.assertIn("summary.txt", archive.namelist()) + self.assertNotIn("logs/performance.json", archive.namelist()) self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("artifacts/")])) artifact = outcome.manifest.artifact_list()[0] archived_bytes = archive.read(f"artifacts/{artifact.relative_path}") @@ -558,6 +564,36 @@ def test_preflight_plan_run_and_package(self) -> None: self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) + def test_performance_report_is_finalized_before_automatic_packaging(self) -> None: + """Performance-mode timing evidence must be present in the automatic ZIP.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + performance_check=True, + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + performance_path = outcome.run_directory / "logs" / "performance.json" + self.assertTrue(performance_path.is_file()) + report = json.loads(performance_path.read_text(encoding="utf-8")) + self.assertEqual(outcome.manifest.run_id, report["run_id"]) + self.assertEqual("core.system.system_info", report["collectors"][0]["id"]) + self.assertIsNotNone(report["collectors"][0]["duration_seconds"]) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged_report = json.loads(archive.read("logs/performance.json")) + self.assertEqual(report, packaged_report) + performance_path.unlink() + with self.assertRaisesRegex(FileNotFoundError, "performance report"): + package_run(outcome) + def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self) -> None: """Only manifest artifacts may enter a package, and their final bytes must match.""" with tempfile.TemporaryDirectory() as temporary: From 914fba4d6038d5f31bc7ad9554088703813140af Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:30:52 +0300 Subject: [PATCH 128/316] fix: require elevation for privileged collectors --- TODO.md | 2 +- logicytics/planner.py | 13 ++++++- tests/test_core_functionality.py | 66 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 80b5ed12..50fb442f 100644 --- a/TODO.md +++ b/TODO.md @@ -331,7 +331,7 @@ Windows platform allows. - [ ] Provide declared capability gates for filesystem reads, registry reads, subprocesses, network capture, raw packet access, browser-data access, and sensitive-file access. -- [ ] Require elevated privilege and explicit user approval for collectors that need +- [x] Require elevated privilege and explicit user approval for collectors that need it; do not grant administrator access to every plugin by default. - [ ] Capture subprocess trees so child processes do not survive a failed collector. - [ ] Use OS/process isolation as the default boundary; treat Python-level import diff --git a/logicytics/planner.py b/logicytics/planner.py index 17669425..232f41ab 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -5,8 +5,9 @@ import sys from dataclasses import dataclass -from logicytics.contracts import CollectorKind, RunRequest +from logicytics.contracts import Capability, CollectorKind, RunRequest from logicytics.discovery import CollectorCandidate, PreflightReport +from logicytics.environment import inspect_environment from logicytics.errors import PlanError, PreflightError @@ -89,4 +90,14 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if missing_capabilities: required = ", ".join(sorted(capability.value for capability in missing_capabilities)) raise PlanError(f"{candidate.metadata.id} requires unapproved capabilities: {required}") + elevated_collectors = sorted( + candidate.metadata.id + for candidate in selected.values() + if candidate.metadata is not None and Capability.ELEVATED_PRIVILEGES in candidate.metadata.capabilities + ) + if elevated_collectors and inspect_environment().is_administrator is not True: + raise PlanError( + "selected collectors require an administrator account: " + + ", ".join(elevated_collectors) + ) return RunPlan(request=request, collectors=_topological_order(selected)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a1e61afd..4af38daa 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -20,6 +20,7 @@ from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, CollectorMetadata, RunRequest, Specialty from logicytics.discovery import preflight +from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError from logicytics.packaging import package_run from logicytics.planner import build_plan @@ -1024,6 +1025,71 @@ def test_capability_gate_requires_explicit_approval(self) -> None: ) self.assertEqual(1, len(plan.collectors)) + def test_elevated_collectors_require_approval_and_administrator_privileges(self) -> None: + """Privilege-sensitive collection must fail closed before launching a worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + secured_collector = _COLLECTOR.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + 'supported_platforms=("win32",),', + 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,),', + ) + collector_path.write_text(secured_collector, encoding="utf-8") + report = preflight(root) + with self.assertRaisesRegex(PlanError, "unapproved capabilities"): + build_plan(report, RunRequest()) + approved = RunRequest(approved_capabilities=(Capability.ELEVATED_PRIVILEGES,)) + for administrator_state in (False, None): + with self.subTest(administrator_state=administrator_state): + environment = EnvironmentReport(administrator_state, True, None) + with patch("logicytics.planner.inspect_environment", return_value=environment): + with self.assertRaisesRegex(PlanError, "administrator account"): + build_plan(report, approved) + with patch( + "logicytics.planner.inspect_environment", + return_value=EnvironmentReport(True, True, None), + ): + plan = build_plan(report, approved) + self.assertEqual(1, len(plan.collectors)) + + def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> None: + """An opt-in privileged plugin must not affect an ordinary core-only plan.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + plugin_path = root / "plugins" / "admin_plugin.py" + plugin_path.parent.mkdir(parents=True) + source = _COLLECTOR.replace("CoreCollector", "PluginCollector") + source = source.replace("SystemInfoCollector", "AdminPluginCollector") + source = source.replace("core.system.system_info", "plugin.admin_plugin") + source = source.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + 'supported_platforms=("win32",),', + 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,),', + ) + plugin_path.write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + with patch("logicytics.planner.inspect_environment") as inspect: + plan = build_plan(report, RunRequest()) + self.assertEqual((), plan.collectors) + inspect.assert_not_called() + enabled = RunRequest( + enable_plugins=True, + approved_capabilities=(Capability.ELEVATED_PRIVILEGES,), + ) + with patch( + "logicytics.planner.inspect_environment", + return_value=EnvironmentReport(False, True, None), + ): + with self.assertRaisesRegex(PlanError, "administrator account"): + build_plan(report, enabled) + if __name__ == "__main__": unittest.main() From a3b35aebb83b1ec1b76436380172445eed190842 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:33:43 +0300 Subject: [PATCH 129/316] fix: require explicit sensitive collector opt-in --- TODO.md | 4 +- logicytics/contracts.py | 2 + tests/test_core_functionality.py | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 50fb442f..52ae32c0 100644 --- a/TODO.md +++ b/TODO.md @@ -419,7 +419,7 @@ Windows platform allows. - [ ] Require an explicit consent/authorization acknowledgement before collection, with a clear summary of the selected categories and sensitive outputs. -- [ ] Mark collectors that may copy credentials, private keys, personal files, +- [x] Mark collectors that may copy credentials, private keys, personal files, browser data, or packet contents as sensitive and make them opt-in for the default profile. - [ ] Add per-collector timeouts, retry policies, maximum output size, maximum file @@ -462,7 +462,7 @@ Windows platform allows. overrides. - [ ] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` with documented collector membership. -- [ ] Allow explicit include/exclude collector selections to override a profile. +- [x] Allow explicit include/exclude collector selections to override a profile. - [ ] Version the configuration schema and migrate older configuration files. - [ ] Keep remote configuration optional, authenticated/validated, and out of the critical local collection path. diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 34db3652..7d78635a 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -124,6 +124,8 @@ def __post_init__(self) -> None: self._validate_labels("supported_platforms", self.supported_platforms, require_value=True) self._validate_labels("sensitive_data_categories", self.sensitive_data_categories) self._validate_labels("default_profiles", self.default_profiles, require_value=True) + if self.sensitive_data_categories and {"standard", "minimal"}.intersection(self.default_profiles): + raise ValueError("sensitive collectors must not belong to standard or minimal profiles") if not isinstance(self.capabilities, tuple) or not all( isinstance(capability, Capability) for capability in self.capabilities): raise ValueError("metadata capabilities must be a tuple of Capability values") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 4af38daa..22159ab7 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -147,9 +147,76 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "maximum_artifact_bytes": True}) with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): CollectorMetadata(**{**common, "maximum_output_bytes": 8, "maximum_artifact_bytes": 9}) + with self.assertRaisesRegex(ValueError, "sensitive collectors"): + CollectorMetadata(**{**common, "sensitive_data_categories": ("credentials",)}) metadata = CollectorMetadata(**{**common, "maximum_output_bytes": 8}) self.assertEqual(8, metadata.maximum_artifact_bytes) + def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) -> None: + """Default collection excludes sensitive evidence unless explicitly selected.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + (core_directory / "a_standard.py").write_text( + _delayed_collector_source("a_standard", 0.0), + encoding="utf-8", + ) + sensitive_id = "core.system.z_sensitive" + source = _delayed_collector_source("z_sensitive", 0.0).replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SENSITIVE_FILES,),\n' + ' sensitive_data_categories=("credentials",),\n' + ' default_profiles=("deep",),', + ) + (core_directory / "z_sensitive.py").write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + + standard = build_plan(report, RunRequest()) + self.assertEqual(["core.system.a_standard"], [item.metadata.id for item in standard.collectors]) + with self.assertRaisesRegex(PlanError, "unapproved capabilities"): + build_plan(report, RunRequest(include=(sensitive_id,))) + opted_in = build_plan( + report, + RunRequest( + include=(sensitive_id,), + exclude=("core.system.a_standard",), + approved_capabilities=(Capability.SENSITIVE_FILES,), + ), + ) + self.assertEqual([sensitive_id], [item.metadata.id for item in opted_in.collectors]) + deep = build_plan( + report, + RunRequest(profile="deep", approved_capabilities=(Capability.SENSITIVE_FILES,)), + ) + self.assertEqual([sensitive_id], [item.metadata.id for item in deep.collectors]) + + def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: + """A shipped collector cannot silently introduce sensitive default evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text( + _COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' sensitive_data_categories=("credentials",),', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("sensitive collectors", report.invalid[0].runtime_error) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest()) + def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, object]]] = [] From c0d2c3877c22d6ecd79f85e42790b2797d646186 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:35:38 +0300 Subject: [PATCH 130/316] feat: summarize evidence before collection consent --- TODO.md | 2 +- logicytics/runtime.py | 22 +++++++++++++++++++++- tests/test_core_functionality.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 52ae32c0..be5d9417 100644 --- a/TODO.md +++ b/TODO.md @@ -417,7 +417,7 @@ Windows platform allows. ### Make collection safe, bounded, and observable -- [ ] Require an explicit consent/authorization acknowledgement before collection, +- [x] Require an explicit consent/authorization acknowledgement before collection, with a clear summary of the selected categories and sensitive outputs. - [x] Mark collectors that may copy credentials, private keys, personal files, browser data, or packet contents as sensitive and make them opt-in for the diff --git a/logicytics/runtime.py b/logicytics/runtime.py index ec2f768a..53f0cc9a 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -188,7 +188,27 @@ def __init__(self, project_root: Path, configuration: AppConfig) -> None: def run(self, plan: RunPlan) -> RunOutcome: """Execute a preflighted plan and persist the manifest throughout the run.""" if plan.collectors and not plan.request.acknowledge_authorization: - raise PermissionError("collection requires acknowledge_authorization=True") + categories = sorted( + candidate.metadata.specialty.value + if hasattr(candidate.metadata.specialty, "value") + else candidate.metadata.specialty + for candidate in plan.collectors + if candidate.metadata is not None + ) + sensitive_outputs = sorted( + { + category + for candidate in plan.collectors + if candidate.metadata is not None + for category in candidate.metadata.sensitive_data_categories + } + ) + raise PermissionError( + "collection requires --acknowledge-authorization " + "(acknowledge_authorization=True); " + f"selected categories: {', '.join(dict.fromkeys(categories))}; " + f"sensitive outputs: {', '.join(sensitive_outputs) or 'none'}" + ) if plan.request.max_workers > self.configuration.runtime.maximum_workers: raise ValueError("requested workers exceed configured maximum_workers") run_id = f"run-{uuid4().hex}" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 22159ab7..b2911aa5 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -217,6 +217,38 @@ def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: with self.assertRaises(PreflightError): build_plan(report, RunRequest()) + def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) -> None: + """Collection consent must explain requested evidence before creating a workspace.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text( + _COLLECTOR.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SENSITIVE_FILES,),\n' + ' sensitive_data_categories=("credentials", "personal_documents"),\n' + ' default_profiles=("deep",),', + ), + encoding="utf-8", + ) + configuration = default_config(root) + plan = build_plan( + preflight(root), + RunRequest(profile="deep", approved_capabilities=(Capability.SENSITIVE_FILES,)), + ) + with self.assertRaises(PermissionError) as rejected: + RunSupervisor(root, configuration).run(plan) + message = str(rejected.exception) + self.assertIn("--acknowledge-authorization", message) + self.assertIn("selected categories: system", message) + self.assertIn("sensitive outputs: credentials, personal_documents", message) + self.assertFalse(configuration.runtime.output_root.exists()) + def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, object]]] = [] From 4df80c3f537ad557da8eaa25ff53b9096e41394e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:40:04 +0300 Subject: [PATCH 131/316] fix: stream artifacts with bounded atomic isolation --- TODO.md | 4 +- logicytics/artifacts.py | 74 +++++++++++++++++--- logicytics/runtime.py | 1 + tests/test_core_functionality.py | 113 +++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 13 deletions(-) diff --git a/TODO.md b/TODO.md index be5d9417..4cd4382d 100644 --- a/TODO.md +++ b/TODO.md @@ -326,7 +326,7 @@ Windows platform allows. run manifest, while allowing independent collectors to finish. - [ ] Prevent a collector from changing another collector's files, result state, logger configuration, environment, or execution plan. -- [ ] Restrict artifact registration to paths inside the collector workspace and +- [x] Restrict artifact registration to paths inside the collector workspace and copy/stream only approved artifacts into the run artifact store. - [ ] Provide declared capability gates for filesystem reads, registry reads, subprocesses, network capture, raw packet access, browser-data access, and @@ -426,7 +426,7 @@ Windows platform allows. size, maximum file count, and maximum total run size. - [ ] Add cancellation checks inside long filesystem walks, packet capture, memory collection, event-log queries, and copy loops. -- [ ] Prevent path traversal and symlink/junction escapes from leaving the run +- [x] Prevent path traversal and symlink/junction escapes from leaving the run workspace or copying unintended locations. - [ ] Avoid loading entire command output, files, memory maps, or packet sets into memory when streaming is possible. diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index c26fa12b..7c0214e8 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import os import shutil from datetime import datetime, timezone from pathlib import Path @@ -43,6 +44,7 @@ def __init__( source_category: str | None = None, maximum_artifact_bytes: int | None = None, run_output_budget_bytes: int | None = None, + cancellation_file: Path | None = None, ) -> None: self._collector_id = collector_id collector_parts = collector_id.split(".", 2) @@ -71,6 +73,7 @@ def __init__( or run_output_budget_bytes < 1 ): raise ArtifactError("run_output_budget_bytes must be a positive integer") + self._cancellation_file = cancellation_file self._bytes_registered = 0 self._artifacts: list[Artifact] = [] @@ -90,32 +93,31 @@ def register_file( not isinstance(step, str) or not step.strip() for step in transformations ): raise ArtifactError("artifact transformations must be a tuple of non-empty strings") + self._check_cancellation() source = source.resolve() if not source.is_file() or not _is_within(source, self._workspace): raise ArtifactError("artifacts must be regular files inside the collector workspace") - size_bytes = source.stat().st_size - if size_bytes > self._maximum_artifact_bytes: - raise ArtifactError("collector artifact exceeds its declared maximum_artifact_bytes") - if self._bytes_registered + size_bytes > self._maximum_output_bytes: - raise ArtifactError("collector output exceeds its declared maximum_output_bytes") - if self._run_output_budget_bytes is not None and ( - self._bytes_registered + size_bytes > self._run_output_budget_bytes - ): - raise ArtifactError("run output exceeds configured maximum_run_output_bytes") + source_stat = source.stat() + size_bytes = source_stat.st_size + self._check_output_limits(size_bytes) if len(self._artifacts) >= self._maximum_artifact_files: raise ArtifactError("collector artifact count exceeds its declared maximum_artifact_files") relative_source = source.relative_to(self._workspace) safe_collector_id = self._collector_id.replace(".", "_") destination = self._artifact_root / safe_collector_id / relative_source + if not _is_within(destination, self._artifact_root): + raise ArtifactError("artifact destination must remain inside the run artifact store") destination.parent.mkdir(parents=True, exist_ok=True) + if not _is_within(destination, self._artifact_root): + raise ArtifactError("artifact destination must remain inside the run artifact store") if destination.exists(): destination = destination.with_name(f"{destination.stem}-{uuid4().hex[:8]}{destination.suffix}") - shutil.copy2(source, destination) + digest = self._copy_artifact(source, destination, size_bytes, source_stat.st_mtime_ns) artifact = Artifact( id=f"artifact.{uuid4().hex}", relative_path=destination.relative_to(self._artifact_root).as_posix(), - sha256=sha256_file(destination), + sha256=digest, size_bytes=size_bytes, media_type=media_type, collector_id=self._collector_id, @@ -126,3 +128,53 @@ def register_file( self._bytes_registered += size_bytes self._artifacts.append(artifact) return artifact + + def _check_cancellation(self) -> None: + if self._cancellation_file is not None and self._cancellation_file.exists(): + raise ArtifactError("artifact registration was cancelled") + + def _check_output_limits(self, size_bytes: int) -> None: + if size_bytes > self._maximum_artifact_bytes: + raise ArtifactError("collector artifact exceeds its declared maximum_artifact_bytes") + if self._bytes_registered + size_bytes > self._maximum_output_bytes: + raise ArtifactError("collector output exceeds its declared maximum_output_bytes") + if self._run_output_budget_bytes is not None and ( + self._bytes_registered + size_bytes > self._run_output_budget_bytes + ): + raise ArtifactError("run output exceeds configured maximum_run_output_bytes") + + def _copy_artifact( + self, + source: Path, + destination: Path, + expected_size: int, + expected_modified_at: int, + ) -> str: + """Stream bounded evidence to an atomic destination while observing cancellation.""" + temporary = destination.with_name(f".{destination.name}.{uuid4().hex}.tmp") + digest = hashlib.sha256() + copied_bytes = 0 + try: + with source.open("rb") as source_stream, temporary.open("xb") as destination_stream: + while block := source_stream.read(1024 * 1024): + self._check_cancellation() + copied_bytes += len(block) + self._check_output_limits(copied_bytes) + if copied_bytes > expected_size: + raise ArtifactError("artifact source changed during registration") + destination_stream.write(block) + digest.update(block) + final_stat = source.stat() + if ( + copied_bytes != expected_size + or final_stat.st_size != expected_size + or final_stat.st_mtime_ns != expected_modified_at + ): + raise ArtifactError("artifact source changed during registration") + shutil.copystat(source, temporary) + self._check_cancellation() + os.replace(temporary, destination) + return digest.hexdigest() + finally: + if temporary.exists(): + temporary.unlink() diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 53f0cc9a..5d67834c 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -116,6 +116,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu ), maximum_artifact_bytes=metadata.maximum_artifact_bytes, run_output_budget_bytes=int(payload["run_output_budget_bytes"]), + cancellation_file=Path(str(payload["cancellation_file"])), ) context = CollectorContext( run_id=str(payload["run_id"]), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index b2911aa5..d1b04783 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -427,6 +427,119 @@ def test_artifacts_cannot_escape_collector_workspace(self) -> None: writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) with self.assertRaises(ArtifactError): writer.register_file(outside) + linked_source = workspace / "linked-source.txt" + linked_source.symlink_to(outside) + with self.assertRaisesRegex(ArtifactError, "collector workspace"): + writer.register_file(linked_source) + + def test_artifact_destination_symlink_cannot_escape_run_store(self) -> None: + """Pre-existing artifact junctions or symlinks must never redirect evidence outside the run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + outside = root / "outside" + workspace.mkdir() + artifact_root.mkdir() + outside.mkdir() + source = workspace / "report.txt" + source.write_text("evidence\n", encoding="utf-8") + (artifact_root / "core_system_test").symlink_to(outside, target_is_directory=True) + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) + + with self.assertRaisesRegex(ArtifactError, "destination"): + writer.register_file(source) + self.assertEqual([], list(outside.iterdir())) + + def test_cancelled_artifact_copy_never_publishes_partial_evidence(self) -> None: + """Cancellation must stop staging without creating a final or temporary artifact.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "report.txt" + source.write_text("evidence\n", encoding="utf-8") + cancellation_file = root / ".cancelled" + cancellation_file.touch() + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 1024, + 1, + cancellation_file=cancellation_file, + ) + + with self.assertRaisesRegex(ArtifactError, "cancelled"): + writer.register_file(source) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list(artifact_root.rglob("*"))) + + def test_mutating_artifact_source_cannot_bypass_declared_byte_limits(self) -> None: + """Growing an evidence file after its initial size check must not publish partial data.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "report.bin" + source.write_bytes(b"1234") + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 4, + 1, + ) + original_copy = writer._copy_artifact + + def mutate_before_copy(*arguments): + source.write_bytes(b"12345") + return original_copy(*arguments) + + with patch.object(writer, "_copy_artifact", side_effect=mutate_before_copy): + with self.assertRaisesRegex(ArtifactError, "maximum_artifact_bytes"): + writer.register_file(source) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list((artifact_root / "core_system_test").iterdir())) + + def test_cancellation_during_streaming_removes_incomplete_artifact(self) -> None: + """Mid-transfer cancellation must remove temporary evidence and publish nothing.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "large.bin" + source.write_bytes(b"x" * (2 * 1024 * 1024)) + cancellation_file = root / ".cancelled" + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 3 * 1024 * 1024, + 1, + cancellation_file=cancellation_file, + ) + original_check = writer._check_cancellation + checks = 0 + + def cancel_during_second_chunk() -> None: + nonlocal checks + checks += 1 + if checks == 3: + cancellation_file.touch() + original_check() + + with patch.object(writer, "_check_cancellation", side_effect=cancel_during_second_chunk): + with self.assertRaisesRegex(ArtifactError, "cancelled"): + writer.register_file(source) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list((artifact_root / "core_system_test").iterdir())) def test_artifact_registration_enforces_collector_file_count_limit(self) -> None: """A collector cannot exceed its declared artifact count even with tiny files.""" From 2c2d388587c557e583be1ae7eeb25f9c7fa997ef Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:43:48 +0300 Subject: [PATCH 132/316] feat: add bounded isolated collector retries --- TODO.md | 2 +- logicytics/contracts.py | 12 +++ logicytics/manifest.py | 2 + logicytics/packaging.py | 1 + logicytics/runtime.py | 110 +++++++++++++++++++++++--- tests/test_core_functionality.py | 129 +++++++++++++++++++++++++++++++ 6 files changed, 246 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 4cd4382d..a60ebc9f 100644 --- a/TODO.md +++ b/TODO.md @@ -422,7 +422,7 @@ Windows platform allows. - [x] Mark collectors that may copy credentials, private keys, personal files, browser data, or packet contents as sensitive and make them opt-in for the default profile. -- [ ] Add per-collector timeouts, retry policies, maximum output size, maximum file +- [x] Add per-collector timeouts, retry policies, maximum output size, maximum file size, maximum file count, and maximum total run size. - [ ] Add cancellation checks inside long filesystem walks, packet capture, memory collection, event-log queries, and copy loops. diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 7d78635a..899ffe99 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from math import isfinite from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field from enum import StrEnum @@ -103,6 +104,8 @@ class CollectorMetadata: maximum_output_bytes: int = 100 * 1024 * 1024 maximum_artifact_bytes: int | None = None maximum_artifact_files: int = 500 + maximum_retries: int = 0 + retry_delay_seconds: float = 0.0 minimum_contract_version: str = CONTRACT_VERSION parallel_safe: bool = True @@ -148,6 +151,15 @@ def __post_init__(self) -> None: raise ValueError(f"metadata {name} must be a positive integer") if self.maximum_artifact_bytes > self.maximum_output_bytes: raise ValueError("metadata maximum_artifact_bytes must not exceed maximum_output_bytes") + if not isinstance(self.maximum_retries, int) or isinstance(self.maximum_retries, bool) or not ( + 0 <= self.maximum_retries <= 3 + ): + raise ValueError("metadata maximum_retries must be an integer from 0 to 3") + if not isinstance(self.retry_delay_seconds, (int, float)) or isinstance( + self.retry_delay_seconds, + bool, + ) or not isfinite(self.retry_delay_seconds) or not 0 <= self.retry_delay_seconds <= 30: + raise ValueError("metadata retry_delay_seconds must be a number from 0 to 30") if not isinstance(self.parallel_safe, bool): raise ValueError("metadata parallel_safe must be boolean") diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 0e947347..5da59a2e 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -37,6 +37,8 @@ class CollectorRecord: event_count: int = 0 last_progress_at: str | None = None heartbeat_at: str | None = None + attempt_count: int = 0 + retry_history: list[dict[str, Any]] = field(default_factory=list) def apply_result(self, result: CollectorResult, duration_seconds: float | None = None) -> None: """Copy a worker result into this serializable record.""" diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 2cc03f97..d1c6ae8b 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -29,6 +29,7 @@ def _summary(manifest: RunManifest) -> str: [ f"- {record.id}", f" Status: {record.status}", + f" Attempts: {record.attempt_count}", f" Started: {record.started_at or 'not started'}", f" Finished: {record.finished_at or 'not finished'}", f" Summary: {record.summary or 'none'}", diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 5d67834c..b3036fde 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -27,8 +27,9 @@ RunStatus, ValidationResult, ) +from logicytics.discovery import CollectorCandidate from logicytics.logging import FileEventLogger -from logicytics.manifest import RunManifest, write_manifest, utc_now +from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.packaging import package_manifest from logicytics.planner import RunPlan @@ -295,12 +296,18 @@ def _supervise( cancellation_file: Path, manifest: RunManifest, manifest_path: Path, - records: dict[str, object], + records: dict[str, CollectorRecord], run_logger: FileEventLogger, ) -> None: """Schedule bounded isolated workers and contain each terminal failure.""" pending = list(plan.collectors) + candidates = { + candidate.metadata.id: candidate + for candidate in plan.collectors + if candidate.metadata is not None + } active: dict[str, _ActiveWorker] = {} + retry_not_before: dict[str, float] = {} self._active_workers = active result_queue: multiprocessing.Queue = multiprocessing.get_context("spawn").Queue() worker_limit = min(plan.request.max_workers, self.configuration.runtime.maximum_workers) @@ -319,6 +326,8 @@ def _supervise( break candidate = pending[0] assert candidate.metadata is not None + if monotonic() < retry_not_before.get(candidate.metadata.id, 0): + break dependency_states = { dependency: records[dependency].status for dependency in candidate.metadata.dependencies @@ -378,6 +387,7 @@ def _supervise( continue output_budget = min(candidate.metadata.maximum_output_bytes, remaining_output_bytes) pending.pop(0) + retry_not_before.pop(candidate.metadata.id, None) workspace = workspace_root / candidate.metadata.id.replace(".", "_") payload: dict[str, object] = { "run_id": run_id, @@ -398,6 +408,7 @@ def _supervise( records[candidate.metadata.id].status = "running" records[candidate.metadata.id].started_at = utc_now() records[candidate.metadata.id].heartbeat_at = utc_now() + records[candidate.metadata.id].attempt_count += 1 process.start() active[candidate.metadata.id] = _ActiveWorker( candidate.metadata.id, @@ -423,9 +434,19 @@ def _supervise( if worker is not None: worker.process.join(timeout=1) self._apply_worker_result(records[collector_id], _result_from_dict(message["result"]), worker) - committed_output_bytes += self._collector_artifact_bytes(artifact_root, collector_id) + artifact_bytes = self._collector_artifact_bytes(artifact_root, collector_id) + committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) - run_logger.event("info", "collector_finished", collector_id=collector_id) + if not self._schedule_retry( + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, + ): + run_logger.event("info", "collector_finished", collector_id=collector_id) write_manifest(manifest_path, manifest) for collector_id, worker in tuple(active.items()): @@ -436,12 +457,26 @@ def _supervise( active.pop(collector_id) self._apply_worker_result( records[collector_id], - CollectorResult(CollectorStatus.FAILED, "collector exceeded its declared timeout"), + CollectorResult( + CollectorStatus.FAILED, + "collector exceeded its declared timeout", + errors=(f"collector exceeded its {worker.timeout_seconds}-second timeout",), + ), worker, ) - committed_output_bytes += self._collector_artifact_bytes(artifact_root, collector_id) + artifact_bytes = self._collector_artifact_bytes(artifact_root, collector_id) + committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) - run_logger.event("error", "collector_timed_out", collector_id=collector_id) + if not self._schedule_retry( + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, + ): + run_logger.event("error", "collector_timed_out", collector_id=collector_id) write_manifest(manifest_path, manifest) elif not worker.process.is_alive(): worker.process.join(timeout=1) @@ -456,12 +491,69 @@ def _supervise( CollectorResult(CollectorStatus.FAILED, "collector exited without a result"), worker, ) - committed_output_bytes += self._collector_artifact_bytes(artifact_root, collector_id) + artifact_bytes = self._collector_artifact_bytes(artifact_root, collector_id) + committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) - run_logger.event("error", "collector_exited_without_result", collector_id=collector_id) + if not self._schedule_retry( + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, + ): + run_logger.event("error", "collector_exited_without_result", collector_id=collector_id) write_manifest(manifest_path, manifest) sleep(0.01) + @staticmethod + def _schedule_retry( + candidate: CollectorCandidate, + record: CollectorRecord, + artifact_bytes: int, + pending: list[CollectorCandidate], + retry_not_before: dict[str, float], + cancellation_file: Path, + run_logger: FileEventLogger, + ) -> bool: + """Retry only explicitly permitted failed attempts that produced no evidence.""" + assert candidate.metadata is not None + if ( + record.status != CollectorStatus.FAILED.value + or record.attempt_count > candidate.metadata.maximum_retries + or artifact_bytes != 0 + or cancellation_file.exists() + ): + return False + record.retry_history.append( + { + "attempt": record.attempt_count, + "status": record.status, + "started_at": record.started_at, + "finished_at": record.finished_at, + "summary": record.summary, + "errors": list(record.errors), + "duration_seconds": record.duration_seconds, + } + ) + record.status = "retry_pending" + record.summary = "collector retry scheduled" + record.errors = [] + record.artifacts = [] + record.started_at = None + record.finished_at = None + record.duration_seconds = None + retry_not_before[record.id] = monotonic() + candidate.metadata.retry_delay_seconds + pending.insert(0, candidate) + run_logger.event( + "warning", + "collector_retry_scheduled", + collector_id=record.id, + attempt=record.attempt_count + 1, + ) + return True + @staticmethod def _collector_artifact_bytes(artifact_root: Path, collector_id: str) -> int: """Count run-owned evidence, including files left by a failed collector.""" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d1b04783..7509d349 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -149,6 +149,14 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "maximum_output_bytes": 8, "maximum_artifact_bytes": 9}) with self.assertRaisesRegex(ValueError, "sensitive collectors"): CollectorMetadata(**{**common, "sensitive_data_categories": ("credentials",)}) + for retries in (-1, 4, True): + with self.subTest(maximum_retries=retries): + with self.assertRaisesRegex(ValueError, "maximum_retries"): + CollectorMetadata(**{**common, "maximum_retries": retries}) + for retry_delay in (-1, 31, True, float("inf")): + with self.subTest(retry_delay_seconds=retry_delay): + with self.assertRaisesRegex(ValueError, "retry_delay_seconds"): + CollectorMetadata(**{**common, "retry_delay_seconds": retry_delay}) metadata = CollectorMetadata(**{**common, "maximum_output_bytes": 8}) self.assertEqual(8, metadata.maximum_artifact_bytes) @@ -997,6 +1005,127 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) + def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> None: + """An explicitly retryable collector may recover before any evidence is registered.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' maximum_retries=1,\n' + ' retry_delay_seconds=0.01,', + ).replace( + ' output = context.workspace / "system.txt"', + ' marker = context.workspace / "attempt.marker"\n' + ' if not marker.exists():\n' + ' marker.write_text("first attempt failed", encoding="utf-8")\n' + ' raise RuntimeError("temporary collector failure")\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + + record = outcome.manifest.collectors[0] + self.assertEqual("succeeded", record.status, record.errors) + self.assertEqual(2, record.attempt_count) + self.assertEqual(1, len(record.retry_history)) + self.assertTrue( + any("temporary collector failure" in error for error in record.retry_history[0]["errors"]) + ) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] + summary = archive.read("summary.txt").decode("utf-8") + self.assertEqual(2, packaged_record["attempt_count"]) + self.assertEqual(1, len(packaged_record["retry_history"])) + self.assertIn("Attempts: 2", summary) + + def test_retry_policy_stops_after_declared_attempt_limit(self) -> None: + """A repeatedly failing collector may not exceed its explicitly declared retry cap.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_retries=1,', + ).replace( + ' output = context.workspace / "system.txt"', + ' raise RuntimeError("persistent collector failure")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertEqual(2, record.attempt_count) + self.assertEqual(1, len(record.retry_history)) + self.assertTrue(any("persistent collector failure" in error for error in record.errors)) + + def test_failed_collector_with_registered_evidence_is_never_retried(self) -> None: + """Evidence-producing failures must not be repeated or overwrite forensic state.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_retries=2,', + ).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("failure after evidence registration")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertEqual(1, record.attempt_count) + self.assertEqual([], record.retry_history) + self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info" / "system.txt").is_file()) + + def test_collector_timeout_preserves_independent_worker_results(self) -> None: + """A timed-out worker is terminated without cancelling unrelated collection.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + timeout_source = _delayed_collector_source("a_timeout", 2.0).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n timeout_seconds=1,', + ) + (core_directory / "a_timeout.py").write_text(timeout_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + _delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("failed", records["core.system.a_timeout"].status) + self.assertTrue(any("timeout" in error for error in records["core.system.a_timeout"].errors)) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertEqual("partial", outcome.manifest.status.value) + def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: """Keyboard cancellation must affect only this run and preserve its partial report.""" with tempfile.TemporaryDirectory() as temporary: From 91baa55bce9a1ac8f492df80dde86fc9142fd7bb Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:47:08 +0300 Subject: [PATCH 133/316] feat: enforce collector memory limits --- TODO.md | 2 +- logicytics/contracts.py | 2 + logicytics/manifest.py | 1 + logicytics/packaging.py | 1 + logicytics/runtime.py | 84 +++++++++++++++++++++++++++++++- tests/test_core_functionality.py | 33 +++++++++++++ 6 files changed, 121 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index a60ebc9f..a3b0de2b 100644 --- a/TODO.md +++ b/TODO.md @@ -318,7 +318,7 @@ Windows platform allows. structured event channel, and artifact staging area. - [x] Pass only a serializable `CollectorContext`/request payload into the worker; do not share mutable engine objects across collectors. -- [ ] Enforce declared timeout, memory/output limits, file-count limits, and +- [x] Enforce declared timeout, memory/output limits, file-count limits, and cancellation through the supervisor. - [ ] Terminate and clean up only the failed collector's process and workspace when it crashes, hangs, exceeds limits, or is cancelled. diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 899ffe99..d10365de 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -101,6 +101,7 @@ class CollectorMetadata: dependencies: tuple[str, ...] = () default_profiles: tuple[str, ...] = ("standard",) timeout_seconds: int = 60 + maximum_memory_bytes: int = 512 * 1024 * 1024 maximum_output_bytes: int = 100 * 1024 * 1024 maximum_artifact_bytes: int | None = None maximum_artifact_files: int = 500 @@ -142,6 +143,7 @@ def __post_init__(self) -> None: object.__setattr__(self, "maximum_artifact_bytes", self.maximum_output_bytes) for name in ( "timeout_seconds", + "maximum_memory_bytes", "maximum_output_bytes", "maximum_artifact_bytes", "maximum_artifact_files", diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 5da59a2e..a623bdaa 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -37,6 +37,7 @@ class CollectorRecord: event_count: int = 0 last_progress_at: str | None = None heartbeat_at: str | None = None + peak_memory_bytes: int = 0 attempt_count: int = 0 retry_history: list[dict[str, Any]] = field(default_factory=list) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index d1c6ae8b..bbe3d759 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -30,6 +30,7 @@ def _summary(manifest: RunManifest) -> str: f"- {record.id}", f" Status: {record.status}", f" Attempts: {record.attempt_count}", + f" Peak memory bytes: {record.peak_memory_bytes}", f" Started: {record.started_at or 'not started'}", f" Finished: {record.finished_at or 'not finished'}", f" Summary: {record.summary or 'none'}", diff --git a/logicytics/runtime.py b/logicytics/runtime.py index b3036fde..48f3c77e 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -3,6 +3,7 @@ from __future__ import annotations import contextlib +import ctypes import importlib.util import json import multiprocessing @@ -49,6 +50,7 @@ class _ActiveWorker: process: multiprocessing.Process started_at: float timeout_seconds: int + maximum_memory_bytes: int workspace: Path parallel_safe: bool reserved_output_bytes: int @@ -415,6 +417,7 @@ def _supervise( process, monotonic(), candidate.metadata.timeout_seconds, + candidate.metadata.maximum_memory_bytes, workspace, candidate.metadata.parallel_safe, output_budget, @@ -452,7 +455,42 @@ def _supervise( for collector_id, worker in tuple(active.items()): if self._refresh_worker_progress(records[collector_id], worker): write_manifest(manifest_path, manifest) - if monotonic() - worker.started_at > worker.timeout_seconds: + memory_bytes = self._worker_memory_bytes(worker.process) + if memory_bytes is not None: + records[collector_id].peak_memory_bytes = max( + records[collector_id].peak_memory_bytes, + memory_bytes, + ) + if memory_bytes is not None and memory_bytes > worker.maximum_memory_bytes: + self._terminate_process_tree(worker.process) + active.pop(collector_id) + self._apply_worker_result( + records[collector_id], + CollectorResult( + CollectorStatus.FAILED, + "collector exceeded its declared memory limit", + errors=( + f"collector working set {memory_bytes} exceeded " + f"maximum_memory_bytes={worker.maximum_memory_bytes}", + ), + ), + worker, + ) + artifact_bytes = self._collector_artifact_bytes(artifact_root, collector_id) + committed_output_bytes += artifact_bytes + self._cleanup_worker_temporary_directory(worker) + if not self._schedule_retry( + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, + ): + run_logger.event("error", "collector_memory_limit_exceeded", collector_id=collector_id) + write_manifest(manifest_path, manifest) + elif monotonic() - worker.started_at > worker.timeout_seconds: self._terminate_process_tree(worker.process) active.pop(collector_id) self._apply_worker_result( @@ -507,6 +545,50 @@ def _supervise( write_manifest(manifest_path, manifest) sleep(0.01) + @staticmethod + def _worker_memory_bytes(process: multiprocessing.Process) -> int | None: + """Return one worker's resident working set using local OS facilities.""" + if process.pid is None: + return None + if os.name == "nt": + class ProcessMemoryCounters(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_ulong), + ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + handle = ctypes.windll.kernel32.OpenProcess(0x0410, False, process.pid) + if not handle: + return None + try: + counters = ProcessMemoryCounters() + counters.cb = ctypes.sizeof(counters) + if not ctypes.windll.psapi.GetProcessMemoryInfo( + handle, + ctypes.byref(counters), + counters.cb, + ): + return None + return int(counters.WorkingSetSize) + finally: + ctypes.windll.kernel32.CloseHandle(handle) + status_path = Path(f"/proc/{process.pid}/status") + try: + for line in status_path.read_text(encoding="ascii").splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) * 1024 + except (OSError, ValueError, IndexError): + return None + return None + @staticmethod def _schedule_retry( candidate: CollectorCandidate, diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 7509d349..65dec75e 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -4,6 +4,7 @@ import hashlib import json +import os import tempfile import unittest import zipfile @@ -143,6 +144,8 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "version": "four"}) with self.assertRaisesRegex(ValueError, "maximum_artifact_files"): CollectorMetadata(**{**common, "maximum_artifact_files": 0}) + with self.assertRaisesRegex(ValueError, "maximum_memory_bytes"): + CollectorMetadata(**{**common, "maximum_memory_bytes": True}) with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): CollectorMetadata(**{**common, "maximum_artifact_bytes": True}) with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): @@ -1126,6 +1129,36 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertEqual("partial", outcome.manifest.status.value) + @unittest.skipUnless(os.name == "nt", "Windows working-set enforcement test") + def test_collector_memory_limit_preserves_independent_worker_results(self) -> None: + """A memory-limit violation terminates only the offending isolated worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + limited_source = _delayed_collector_source("a_limited", 2.0).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_memory_bytes=1,', + ) + (core_directory / "a_limited.py").write_text(limited_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + _delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + limited = records["core.system.a_limited"] + self.assertEqual("failed", limited.status) + self.assertGreater(limited.peak_memory_bytes, 1) + self.assertTrue(any("maximum_memory_bytes=1" in error for error in limited.errors)) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertEqual("partial", outcome.manifest.status.value) + def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: """Keyboard cancellation must affect only this run and preserve its partial report.""" with tempfile.TemporaryDirectory() as temporary: From 23b6294037ffdbc30f402fe4551d61eade0749c9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:53:00 +0300 Subject: [PATCH 134/316] fix: redact secrets from run diagnostics --- TODO.md | 2 +- logicytics/configuration.py | 3 +- logicytics/logging.py | 5 +- logicytics/manifest.py | 7 +- logicytics/redaction.py | 71 ++++++++++++++++ tests/test_core_functionality.py | 137 ++++++++++++++++++++++++++++++- 6 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 logicytics/redaction.py diff --git a/TODO.md b/TODO.md index a3b0de2b..03cc955a 100644 --- a/TODO.md +++ b/TODO.md @@ -430,7 +430,7 @@ Windows platform allows. workspace or copying unintended locations. - [ ] Avoid loading entire command output, files, memory maps, or packet sets into memory when streaming is possible. -- [ ] Redact secrets from logs and from diagnostic metadata even when the artifact +- [x] Redact secrets from logs and from diagnostic metadata even when the artifact itself is intentionally collected. - [ ] Never place credentials or raw sensitive evidence in console logs, exception messages, temporary filenames, or update requests. diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 2cf1d850..2ff5f087 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -8,6 +8,7 @@ from typing import Any, Mapping from logicytics.errors import PlanError +from logicytics.redaction import redact_mapping SCHEMA_VERSION = 4 DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 @@ -71,7 +72,7 @@ def to_manifest_dict(self) -> dict[str, Any]: """Return a JSON-safe, non-secret configuration snapshot.""" data = asdict(self) data["runtime"]["output_root"] = str(self.runtime.output_root) - return data + return redact_mapping(data) def default_config(project_root: Path) -> AppConfig: diff --git a/logicytics/logging.py b/logicytics/logging.py index 5465fec7..b4d8c0f1 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -10,6 +10,7 @@ from typing import Callable, ParamSpec, TypeVar from logicytics.contracts import EventLogger +from logicytics.redaction import redact_mapping, redact_text Parameters = ParamSpec("Parameters") Result = TypeVar("Result") @@ -29,13 +30,13 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: payload: dict[str, object] = { "at": datetime.now(timezone.utc).isoformat(), "level": level.lower(), - "message": message, + "message": redact_text(message), "run_id": self.run_id, } if self.collector_id is not None: payload["collector_id"] = self.collector_id if fields: - payload["fields"] = fields + payload["fields"] = redact_mapping(fields) with self.path.open("a", encoding="utf-8") as stream: stream.write(json.dumps(payload, sort_keys=True) + "\n") diff --git a/logicytics/manifest.py b/logicytics/manifest.py index a623bdaa..2232c6cd 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -13,6 +13,7 @@ from typing import Any, Mapping from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus +from logicytics.redaction import redact_mapping, redact_text def utc_now() -> str: @@ -44,10 +45,10 @@ class CollectorRecord: def apply_result(self, result: CollectorResult, duration_seconds: float | None = None) -> None: """Copy a worker result into this serializable record.""" self.status = result.status.value - self.summary = result.summary - self.errors = list(result.errors) + self.summary = redact_text(result.summary) + self.errors = [redact_text(error) for error in result.errors] self.artifacts = [artifact.to_dict() for artifact in result.artifacts] - self.metrics = dict(result.metrics) + self.metrics = redact_mapping(result.metrics) self.duration_seconds = duration_seconds self.finished_at = utc_now() diff --git a/logicytics/redaction.py b/logicytics/redaction.py new file mode 100644 index 00000000..9bc28e6d --- /dev/null +++ b/logicytics/redaction.py @@ -0,0 +1,71 @@ +"""Centralized secret redaction for diagnostic metadata and structured logs.""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +REDACTED = "[REDACTED]" + +_SECRET_KEY_SUFFIXES = ( + "password", + "passwd", + "pwd", + "secret", + "token", + "authorization", + "cookie", + "credential", + "credentials", + "apikey", + "accesskey", + "privatekey", + "recoverykey", + "sessionkey", +) +_BEARER_PATTERN = re.compile(r"(?i)(\bauthorization\s*[:=]\s*bearer\s+)[^\s,;'\"]+") +_ASSIGNMENT_PATTERN = re.compile( + r"(?i)(\b[\w.-]*(?:password|passwd|pwd|secret|token|authorization|cookie|" + r"credentials?|api[_-]?key|access[_-]?key|private[_-]?key|recovery[_-]?key|" + r"session[_-]?key)\s*[:=]\s*['\"]?)[^\s,;'\"]+" +) +_PRIVATE_KEY_PATTERN = re.compile( + r"-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----", + re.IGNORECASE, +) + + +def _is_secret_key(key: str) -> bool: + """Recognize secret-valued fields while preserving dotted collector identities.""" + if key.startswith(("core.", "plugin.")): + return False + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + return any(normalized.endswith(suffix) for suffix in _SECRET_KEY_SUFFIXES) + + +def redact_text(value: str) -> str: + """Remove common inline assignments, bearer credentials, and private-key blocks.""" + value = _PRIVATE_KEY_PATTERN.sub(REDACTED, value) + value = _BEARER_PATTERN.sub(lambda match: f"{match.group(1)}{REDACTED}", value) + return _ASSIGNMENT_PATTERN.sub(lambda match: f"{match.group(1)}{REDACTED}", value) + + +def _redact_value(value: Any) -> Any: + if isinstance(value, Mapping): + return redact_mapping(value) + if isinstance(value, list): + return [_redact_value(item) for item in value] + if isinstance(value, tuple): + return tuple(_redact_value(item) for item in value) + if isinstance(value, str): + return redact_text(value) + return value + + +def redact_mapping(values: Mapping[str, Any]) -> dict[str, Any]: + """Return a recursively sanitized copy without changing worker-owned settings.""" + return { + key: REDACTED if _is_secret_key(key) else _redact_value(value) + for key, value in values.items() + } diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 65dec75e..64c5acf0 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -16,7 +16,7 @@ from logicytics.command_runner import parse_level_messages, run_command from logicytics.cli import _parser, _request from logicytics.file_listing import list_files -from logicytics.logging import deprecated, raise_logged, timed +from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, CollectorMetadata, RunRequest, Specialty @@ -296,6 +296,28 @@ def add(left: int, right: int) -> int: self.assertEqual(3, add(1, 2)) self.assertEqual(["function_started", "function_finished"], [event[1] for event in events]) + def test_structured_event_logger_redacts_secret_fields_and_inline_credentials(self) -> None: + """Structured diagnostics must preserve useful fields without exposing secrets.""" + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "events.jsonl" + logger = FileEventLogger(path, run_id="test-run", collector_id="core.system.private_keys") + logger.event( + "INFO", + "Authorization: Bearer bearer-value password=message-value", + password="field-value", + api_key="api-value", + ordinary="safe", + ) + contents = path.read_text(encoding="utf-8") + for secret in ("bearer-value", "message-value", "field-value", "api-value"): + self.assertNotIn(secret, contents) + event = json.loads(contents) + self.assertEqual("info", event["level"]) + self.assertEqual("core.system.private_keys", event["collector_id"]) + self.assertEqual("[REDACTED]", event["fields"]["password"]) + self.assertEqual("[REDACTED]", event["fields"]["api_key"]) + self.assertEqual("safe", event["fields"]["ordinary"]) + def test_file_listing_filters_and_normalizes_files(self) -> None: """Recursive file discovery must filter extensions and excluded directories.""" with tempfile.TemporaryDirectory() as temporary: @@ -328,6 +350,34 @@ def test_configuration_schema_version_is_enforced(self) -> None: with self.assertRaisesRegex(PlanError, "package_completed_runs"): load_config(root) + def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings(self) -> None: + """Manifest snapshots hide credentials while collectors retain configured access.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_id = "core.system.private_keys" + settings = { + "password": "password-value", + "nested": {"refresh_token": "token-value", "cookie": "cookie-value"}, + "private_key": "private-value", + "ordinary": "safe", + } + (root / "logicytics.json").write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + configuration = load_config(root) + self.assertEqual(settings, configuration.settings_for(collector_id)) + snapshot = configuration.to_manifest_dict() + manifest_settings = snapshot["collector_settings"][collector_id] + self.assertEqual("[REDACTED]", manifest_settings["password"]) + self.assertEqual("[REDACTED]", manifest_settings["nested"]["refresh_token"]) + self.assertEqual("[REDACTED]", manifest_settings["nested"]["cookie"]) + self.assertEqual("[REDACTED]", manifest_settings["private_key"]) + self.assertEqual("safe", manifest_settings["ordinary"]) + for secret in ("password-value", "token-value", "cookie-value", "private-value"): + self.assertNotIn(secret, json.dumps(snapshot)) + self.assertEqual(settings, configuration.settings_for(collector_id)) + def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> None: """Runtime worker limits and output locations must retain strict JSON types.""" with tempfile.TemporaryDirectory() as temporary: @@ -788,6 +838,91 @@ def test_preflight_plan_run_and_package(self) -> None: self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) + def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: + """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' password = context.settings["password"]\n' + ' token = context.settings["nested"]["access_token"]\n' + ' output = context.workspace / "system.txt"', + ).replace( + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ' output.write_text(f"password={password} token={token}", encoding="utf-8")\n' + ' context.logger.event("info", f"password={password}", access_token=token)\n' + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' return CollectorResult.succeeded(f"password={password}", (artifact,))', + ), + encoding="utf-8", + ) + (root / "logicytics.json").write_text( + json.dumps( + { + "schema_version": 4, + "collectors": { + "core.system.system_info": { + "password": "evidence-password", + "nested": {"access_token": "evidence-token"}, + } + }, + } + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, load_config(root)).run(plan) + self.assertEqual("succeeded", outcome.manifest.status.value) + self.assertEqual("password=[REDACTED]", outcome.manifest.collectors[0].summary) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + artifact = outcome.manifest.artifact_list()[0] + self.assertEqual( + "password=evidence-password token=evidence-token", + archive.read(f"artifacts/{artifact.relative_path}").decode("utf-8"), + ) + diagnostics = "\n".join( + archive.read(name).decode("utf-8") + for name in archive.namelist() + if not name.startswith("artifacts/") + ) + self.assertNotIn("evidence-password", diagnostics) + self.assertNotIn("evidence-token", diagnostics) + self.assertIn("[REDACTED]", diagnostics) + + def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) -> None: + """Collector crashes remain actionable without leaking inline credentials.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' raise RuntimeError("password=crash-password token=crash-token")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn("RuntimeError", "\n".join(outcome.manifest.collectors[0].errors)) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + diagnostics = "\n".join( + archive.read(name).decode("utf-8") + for name in archive.namelist() + if not name.startswith("artifacts/") + ) + self.assertNotIn("crash-password", diagnostics) + self.assertNotIn("crash-token", diagnostics) + self.assertIn("[REDACTED]", diagnostics) + def test_performance_report_is_finalized_before_automatic_packaging(self) -> None: """Performance-mode timing evidence must be present in the automatic ZIP.""" with tempfile.TemporaryDirectory() as temporary: From 24441babb2a849a0fc81f3cd164172cd3d7d946f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 13:58:29 +0300 Subject: [PATCH 135/316] fix: confine and stream manifest-led evidence packages --- TODO.md | 2 +- logicytics/packaging.py | 86 ++++++++++++----- tests/test_core_functionality.py | 153 +++++++++++++++++++++++++++++-- 3 files changed, 210 insertions(+), 31 deletions(-) diff --git a/TODO.md b/TODO.md index 03cc955a..4b91ba3a 100644 --- a/TODO.md +++ b/TODO.md @@ -486,7 +486,7 @@ Windows platform allows. ### Improve packaging and evidence integrity - [x] Package only finalized artifacts referenced by the manifest. -- [ ] Stream ZIP creation and hash calculation to support large runs. +- [x] Stream ZIP creation and hash calculation to support large runs. - [x] Add per-artifact SHA-256 values and a package-level SHA-256 value. - [x] Include a machine-readable manifest and a human-readable summary report. - [x] Include collector status, skipped/failed reasons, and collection timestamps diff --git a/logicytics/packaging.py b/logicytics/packaging.py index bbe3d759..89f33194 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -5,15 +5,18 @@ import hashlib import os import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, BinaryIO from logicytics.artifacts import sha256_file +from logicytics.contracts import Artifact from logicytics.manifest import RunManifest, write_manifest if TYPE_CHECKING: from logicytics.runtime import RunOutcome +_STREAM_BLOCK_BYTES = 1024 * 1024 + def _summary(manifest: RunManifest) -> str: lines = [ @@ -47,40 +50,79 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ """Resolve and verify every manifest-declared artifact before packaging it.""" sources: list[tuple[Path, str]] = [] archive_names: set[str] = set() - for artifact in manifest.artifact_list(): - source = run_directory / "artifacts" / artifact.relative_path - archive_name = f"artifacts/{artifact.relative_path}" - if archive_name in archive_names: - raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") - if not source.is_file(): - raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") - if source.stat().st_size != artifact.size_bytes or sha256_file(source) != artifact.sha256: - raise ValueError(f"manifest artifact verification failed: {artifact.relative_path}") - archive_names.add(archive_name) - sources.append((source, archive_name)) + artifact_root = (run_directory / "artifacts").resolve() + for record in manifest.collectors: + for item in record.artifacts: + artifact = Artifact(**item) + if artifact.collector_id != record.id: + raise ValueError(f"manifest artifact collector ownership is invalid: {artifact.relative_path}") + relative = PurePosixPath(artifact.relative_path) + owner = record.id.replace(".", "_") + if ( + not artifact.relative_path + or "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != owner + ): + raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") + source = artifact_root.joinpath(*relative.parts) + if not source.is_file(): + raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") + try: + source.resolve(strict=True).relative_to(artifact_root / owner) + except (OSError, ValueError) as error: + raise ValueError( + f"manifest artifact escapes its collector-owned store: {artifact.relative_path}" + ) from error + archive_name = f"artifacts/{relative.as_posix()}" + if archive_name in archive_names: + raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") + if source.stat().st_size != artifact.size_bytes or sha256_file(source) != artifact.sha256: + raise ValueError(f"manifest artifact verification failed: {artifact.relative_path}") + archive_names.add(archive_name) + sources.append((source, archive_name)) return sources def _log_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[Path, str]]: - """Return only run-owned structured logs, preserving their relative paths.""" + """Return only the engine log and manifest-owned collector event channels.""" sources: list[tuple[Path, str]] = [] - for directory in (run_directory / "logs", run_directory / "collectors"): - if not directory.exists(): + root = run_directory.resolve() + candidates = [root / "logs" / "engine.jsonl"] + candidates.extend( + root / "collectors" / record.id.replace(".", "_") / "events.jsonl" + for record in manifest.collectors + ) + for path in candidates: + if not path.exists() and not path.is_symlink(): continue - for path in sorted(directory.rglob("*.jsonl")): - sources.append((path, path.relative_to(run_directory).as_posix())) + if not path.is_file() or path.resolve(strict=True) != path: + raise ValueError(f"diagnostic log escapes its run-owned event channel: {path.name}") + sources.append((path, path.relative_to(root).as_posix())) if manifest.request.get("performance_check") is True: - performance_path = run_directory / "logs" / "performance.json" + performance_path = root / "logs" / "performance.json" if not performance_path.is_file(): raise FileNotFoundError("requested performance report is missing") + if performance_path.resolve(strict=True) != performance_path: + raise ValueError("performance report escapes its run-owned log directory") sources.append((performance_path, "logs/performance.json")) return sources +def _stream_archive_member(archive: zipfile.ZipFile, source: Path, archive_name: str) -> None: + """Copy one approved member into the archive using bounded evidence blocks.""" + with source.open("rb") as reader, archive.open(archive_name, "w", force_zip64=True) as writer: + while block := reader.read(_STREAM_BLOCK_BYTES): + writer.write(block) + + def _sha256_stream(stream: BinaryIO) -> str: """Hash an open binary stream without loading the evidence into memory.""" digest = hashlib.sha256() - for block in iter(lambda: stream.read(1024 * 1024), b""): + for block in iter(lambda: stream.read(_STREAM_BLOCK_BYTES), b""): digest.update(block) return digest.hexdigest() @@ -121,10 +163,10 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: temporary_package = package_path.with_suffix(".zip.tmp") try: with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.write(manifest_path, "manifest.json") - archive.write(summary_path, "summary.txt") + _stream_archive_member(archive, manifest_path, "manifest.json") + _stream_archive_member(archive, summary_path, "summary.txt") for source, archive_name in (*artifact_sources, *log_sources): - archive.write(source, archive_name) + _stream_archive_member(archive, source, archive_name) _verify_archive(temporary_package, manifest, expected_names) os.replace(temporary_package, package_path) finally: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 64c5acf0..19e9904d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -12,6 +12,7 @@ from pathlib import Path from unittest.mock import patch +from logicytics import packaging from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.cli import _parser, _request @@ -971,25 +972,161 @@ def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self with zipfile.ZipFile(package_path) as archive: self.assertNotIn("artifacts/unregistered.txt", archive.namelist()) - original_write = zipfile.ZipFile.write + original_write = packaging._stream_archive_member def tampering_write( archive: zipfile.ZipFile, - filename: str | Path, - arcname: str | None = None, - compress_type: int | None = None, - compresslevel: int | None = None, + filename: Path, + arcname: str, ) -> None: - if arcname and arcname.startswith("artifacts/"): + if arcname.startswith("artifacts/"): archive.writestr(arcname, b"tampered artifact bytes") return - original_write(archive, filename, arcname, compress_type, compresslevel) + original_write(archive, filename, arcname) - with patch.object(zipfile.ZipFile, "write", new=tampering_write): + with patch.object(packaging, "_stream_archive_member", new=tampering_write): with self.assertRaisesRegex(ValueError, "packaged artifact verification failed"): package_run(outcome) self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) + def test_package_excludes_unregistered_collector_and_engine_jsonl_files(self) -> None: + """Collector-created JSONL files cannot bypass the registered artifact contract.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + collector_workspace = outcome.run_directory / "collectors" / "core_system_system_info" + (collector_workspace / "unregistered.jsonl").write_text("secret workspace data", encoding="utf-8") + (outcome.run_directory / "logs" / "unregistered.jsonl").write_text( + "secret engine data", + encoding="utf-8", + ) + + package_path, _ = package_run(outcome) + with zipfile.ZipFile(package_path) as archive: + self.assertIn("logs/engine.jsonl", archive.namelist()) + self.assertIn("collectors/core_system_system_info/events.jsonl", archive.namelist()) + self.assertNotIn("logs/unregistered.jsonl", archive.namelist()) + self.assertNotIn("collectors/core_system_system_info/unregistered.jsonl", archive.namelist()) + + def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership(self) -> None: + """A modified manifest cannot package outside files or another collector's evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + artifact = outcome.manifest.collectors[0].artifacts[0] + original_path = artifact["relative_path"] + artifact["relative_path"] = "../outside.jsonl" + with self.assertRaisesRegex(ValueError, "escapes its collector-owned store"): + package_run(outcome) + artifact["relative_path"] = original_path + artifact["collector_id"] = "core.system.another" + with self.assertRaisesRegex(ValueError, "collector ownership"): + package_run(outcome) + + def test_package_rejects_collector_event_log_symlinks_outside_the_run(self) -> None: + """A collector event-channel symlink must not smuggle external files into a ZIP.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + outside = root / "outside.jsonl" + outside.write_text("outside credentials", encoding="utf-8") + events = outcome.run_directory / "collectors" / "core_system_system_info" / "events.jsonl" + events.unlink() + events.symlink_to(outside) + + with self.assertRaisesRegex(ValueError, "diagnostic log escapes"): + package_run(outcome) + + def test_package_rejects_manifest_artifact_symlinks_outside_collector_storage(self) -> None: + """Matching bytes cannot make an externally redirected artifact safe to package.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + artifact = outcome.manifest.artifact_list()[0] + stored = outcome.run_directory / "artifacts" / artifact.relative_path + outside = root / "outside.txt" + outside.write_bytes(stored.read_bytes()) + stored.unlink() + stored.symlink_to(outside) + + with self.assertRaisesRegex(ValueError, "escapes its collector-owned store"): + package_run(outcome) + + def test_package_streams_large_evidence_and_hashes_with_bounded_reads(self) -> None: + """Large artifacts must enter ZIP/hash verification without whole-file reads.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ' output.write_bytes(b"x" * (2 * 1024 * 1024 + 17))\n' + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + artifact = outcome.manifest.artifact_list()[0] + source = outcome.run_directory / "artifacts" / artifact.relative_path + expected_package_path = Path(outcome.manifest.package["path"]) + original_open = Path.open + read_sizes: list[tuple[Path, int]] = [] + + class BoundedReader: + def __init__(self, stream: object, path: Path) -> None: + self.stream = stream + self.path = path + + def __enter__(self) -> "BoundedReader": + self.stream.__enter__() + return self + + def __exit__(self, *arguments: object) -> object: + return self.stream.__exit__(*arguments) + + def read(self, size: int = -1) -> bytes: + if not 0 < size <= 1024 * 1024: + raise AssertionError(f"unbounded evidence read requested: {size}") + read_sizes.append((self.path, size)) + return self.stream.read(size) + + def guarded_open(path: Path, *arguments: object, **options: object) -> object: + stream = original_open(path, *arguments, **options) + if path in {source, expected_package_path} and arguments and arguments[0] == "rb": + return BoundedReader(stream, path) + return stream + + with patch.object(Path, "open", new=guarded_open): + package_path, hash_path = package_run(outcome) + self.assertGreaterEqual(len(read_sizes), 6) + self.assertEqual({source, expected_package_path}, {path for path, _ in read_sizes}) + self.assertEqual(artifact.size_bytes, 2 * 1024 * 1024 + 17) + self.assertTrue(package_path.is_file()) + self.assertTrue(hash_path.is_file()) + def test_parallel_completion_preserves_deterministic_manifest_order(self) -> None: """Collector completion order must not reorder the preflighted execution plan.""" with tempfile.TemporaryDirectory() as temporary: From edacbf49b5ef126c7933648e4849a10ee8d22d98 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 14:02:38 +0300 Subject: [PATCH 136/316] fix: preserve evidence and finalize isolated collector failures --- TODO.md | 2 +- logicytics/manifest.py | 4 + logicytics/packaging.py | 4 + logicytics/runtime.py | 82 ++++++++++++++++----- tests/test_core_functionality.py | 122 +++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 19 deletions(-) diff --git a/TODO.md b/TODO.md index 4b91ba3a..f2060892 100644 --- a/TODO.md +++ b/TODO.md @@ -340,7 +340,7 @@ Windows platform allows. configuration, install packages, update the repository, or launch another collector through the public context API. - [x] Provide a supervisor heartbeat and last-progress timestamp for every worker. -- [ ] Make the final package include the isolation result and failure reason for +- [x] Make the final package include the isolation result and failure reason for every selected collector. ### Replace the current architectural model diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 2232c6cd..cf96ce44 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -38,6 +38,10 @@ class CollectorRecord: event_count: int = 0 last_progress_at: str | None = None heartbeat_at: str | None = None + isolation_mode: str = "process" + worker_pid: int | None = None + worker_exit_code: int | None = None + termination_reason: str | None = None peak_memory_bytes: int = 0 attempt_count: int = 0 retry_history: list[dict[str, Any]] = field(default_factory=list) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 89f33194..29add0d6 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -32,6 +32,10 @@ def _summary(manifest: RunManifest) -> str: [ f"- {record.id}", f" Status: {record.status}", + f" Isolation: {record.isolation_mode}", + f" Worker PID: {record.worker_pid if record.worker_pid is not None else 'not started'}", + f" Worker exit code: {record.worker_exit_code if record.worker_exit_code is not None else 'unknown'}", + f" Termination: {record.termination_reason or 'not started'}", f" Attempts: {record.attempt_count}", f" Peak memory bytes: {record.peak_memory_bytes}", f" Started: {record.started_at or 'not started'}", diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 48f3c77e..eb78e307 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -135,26 +135,55 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu settings=dict(payload["settings"]), cancellation_file=Path(str(payload["cancellation_file"])), ) - validation = collector.validate(context) - if not isinstance(validation, ValidationResult): - raise TypeError("validate() must return ValidationResult") - if not validation.valid: + result: CollectorResult | None = None + lifecycle_errors: list[str] = [] + failure_summary = "collector worker crashed" + try: + validation = collector.validate(context) + if not isinstance(validation, ValidationResult): + raise TypeError("validate() must return ValidationResult") + if not validation.valid: + result = CollectorResult( + status=CollectorStatus.SKIPPED, + summary="collector prerequisites were not met", + errors=validation.reasons, + ) + elif context.is_cancelled: + result = CollectorResult(CollectorStatus.CANCELLED, "cancelled before collection") + else: + context.logger.event("info", "collection_started") + result = collector.collect(context) + if not isinstance(result, CollectorResult): + raise TypeError("collect() must return CollectorResult") + if result.artifacts != writer.artifacts: + raise TypeError("collector result artifacts must exactly match registered artifacts") + except BaseException as error: + lifecycle_errors.extend((f"{type(error).__name__}: {error}", traceback.format_exc())) + finally: + try: + collector.cleanup(context) + except BaseException as error: + if not lifecycle_errors: + failure_summary = "collector cleanup failed" + lifecycle_errors.extend( + ( + f"collector cleanup failed: {type(error).__name__}: {error}", + traceback.format_exc(), + ) + ) + if lifecycle_errors: result = CollectorResult( - status=CollectorStatus.SKIPPED, - summary="collector prerequisites were not met", - errors=validation.reasons, + CollectorStatus.FAILED, + failure_summary, + artifacts=writer.artifacts, + errors=(*(result.errors if result is not None else ()), *lifecycle_errors), ) - elif context.is_cancelled: - result = CollectorResult(CollectorStatus.CANCELLED, "cancelled before collection") - else: - context.logger.event("info", "collection_started") - result = collector.collect(context) - if not isinstance(result, CollectorResult): - raise TypeError("collect() must return CollectorResult") - if result.artifacts != writer.artifacts: - raise TypeError("collector result artifacts must exactly match registered artifacts") - collector.cleanup(context) - context.logger.event("info", "collection_finished", status=result.status.value) + assert result is not None + context.logger.event( + "error" if result.status == CollectorStatus.FAILED else "info", + "collection_finished", + status=result.status.value, + ) result_queue.put({"collector_id": metadata.id, "result": _serialize_result(result)}) except BaseException as error: # child processes must always report a terminal result result_queue.put( @@ -412,6 +441,7 @@ def _supervise( records[candidate.metadata.id].heartbeat_at = utc_now() records[candidate.metadata.id].attempt_count += 1 process.start() + records[candidate.metadata.id].worker_pid = process.pid active[candidate.metadata.id] = _ActiveWorker( candidate.metadata.id, process, @@ -464,6 +494,7 @@ def _supervise( if memory_bytes is not None and memory_bytes > worker.maximum_memory_bytes: self._terminate_process_tree(worker.process) active.pop(collector_id) + records[collector_id].termination_reason = "memory_limit_exceeded" self._apply_worker_result( records[collector_id], CollectorResult( @@ -493,6 +524,7 @@ def _supervise( elif monotonic() - worker.started_at > worker.timeout_seconds: self._terminate_process_tree(worker.process) active.pop(collector_id) + records[collector_id].termination_reason = "timeout_exceeded" self._apply_worker_result( records[collector_id], CollectorResult( @@ -524,6 +556,7 @@ def _supervise( if monotonic() - worker.exited_at < 1.0: continue active.pop(collector_id) + records[collector_id].termination_reason = "exited_without_result" self._apply_worker_result( records[collector_id], CollectorResult(CollectorStatus.FAILED, "collector exited without a result"), @@ -617,6 +650,9 @@ def _schedule_retry( "summary": record.summary, "errors": list(record.errors), "duration_seconds": record.duration_seconds, + "worker_pid": record.worker_pid, + "worker_exit_code": record.worker_exit_code, + "termination_reason": record.termination_reason, } ) record.status = "retry_pending" @@ -626,6 +662,9 @@ def _schedule_retry( record.started_at = None record.finished_at = None record.duration_seconds = None + record.worker_pid = None + record.worker_exit_code = None + record.termination_reason = None retry_not_before[record.id] = monotonic() + candidate.metadata.retry_delay_seconds pending.insert(0, candidate) run_logger.event( @@ -701,12 +740,19 @@ def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) -> None: """Attach result, elapsed time, and progress accounting to one record.""" record.apply_result(result, duration_seconds=round(monotonic() - worker.started_at, 3)) + record.worker_pid = worker.process.pid + record.worker_exit_code = worker.process.exitcode + if record.termination_reason is None: + record.termination_reason = ( + "completed" if result.status == CollectorStatus.SUCCEEDED else result.status.value + ) RunSupervisor._refresh_worker_progress(record, worker) def _cancel_active(self, active, records, manifest, manifest_path, reason: str) -> None: for collector_id, worker in tuple(active.items()): self._terminate_process_tree(worker.process) active.pop(collector_id) + records[collector_id].termination_reason = "cancelled" self._apply_worker_result( records[collector_id], CollectorResult(CollectorStatus.CANCELLED, reason), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 19e9904d..667bb757 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1315,6 +1315,11 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N self.assertTrue( any("temporary collector failure" in error for error in record.retry_history[0]["errors"]) ) + self.assertIsInstance(record.worker_pid, int) + self.assertIsInstance(record.retry_history[0]["worker_pid"], int) + self.assertNotEqual(record.worker_pid, record.retry_history[0]["worker_pid"]) + self.assertEqual("failed", record.retry_history[0]["termination_reason"]) + self.assertEqual("completed", record.termination_reason) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] summary = archive.read("summary.txt").decode("utf-8") @@ -1373,6 +1378,121 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non self.assertEqual(1, record.attempt_count) self.assertEqual([], record.retry_history) self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info" / "system.txt").is_file()) + self.assertEqual(1, len(record.artifacts)) + self.assertEqual(1, len(outcome.manifest.artifact_list())) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + self.assertIn("artifacts/core_system_system_info/system.txt", archive.namelist()) + + def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results(self) -> None: + """A failed process finalizes once, preserves evidence, and cannot stop its neighbor.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + failing_source = _delayed_collector_source("a_failed", 0.0).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("collection failed after evidence registration")', + ).replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' (context.workspace / "cleanup.marker").write_text("cleaned", encoding="utf-8")\n' + ' (context.temporary_directory / "scratch.txt").write_text("scratch", encoding="utf-8")', + ) + (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + _delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + failed = records["core.system.a_failed"] + succeeded = records["core.system.z_independent"] + workspace = outcome.run_directory / "collectors" / "core_system_a_failed" + + self.assertEqual("partial", outcome.manifest.status.value) + self.assertEqual("failed", failed.status) + self.assertEqual("succeeded", succeeded.status) + self.assertEqual("cleaned", (workspace / "cleanup.marker").read_text(encoding="utf-8")) + self.assertFalse((workspace / "tmp").exists()) + self.assertEqual(1, len(failed.artifacts)) + self.assertIn("RuntimeError", "\n".join(failed.errors)) + self.assertEqual("process", failed.isolation_mode) + self.assertIsInstance(failed.worker_pid, int) + self.assertEqual("failed", failed.termination_reason) + self.assertEqual("completed", succeeded.termination_reason) + self.assertNotEqual(failed.worker_pid, succeeded.worker_pid) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged = {item["id"]: item for item in json.loads(archive.read("manifest.json"))["collectors"]} + summary = archive.read("summary.txt").decode("utf-8") + self.assertIn("artifacts/core_system_a_failed/system.txt", archive.namelist()) + self.assertEqual("failed", packaged["core.system.a_failed"]["status"]) + self.assertEqual("process", packaged["core.system.a_failed"]["isolation_mode"]) + self.assertEqual(failed.worker_pid, packaged["core.system.a_failed"]["worker_pid"]) + self.assertEqual("succeeded", packaged["core.system.z_independent"]["status"]) + self.assertIn("Isolation: process", summary) + self.assertIn("Termination: failed", summary) + self.assertIn("collection failed after evidence registration", summary) + + def test_cleanup_failure_preserves_original_collector_crash_and_independent_results(self) -> None: + """Cleanup errors must be reported without replacing the original failure.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + failing_source = _delayed_collector_source("a_failed", 0.0).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("original collection failure")', + ).replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' raise ValueError("secondary cleanup failure")', + ) + (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + _delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + errors = "\n".join(records["core.system.a_failed"].errors) + + self.assertEqual("failed", records["core.system.a_failed"].status) + self.assertIn("RuntimeError: original collection failure", errors) + self.assertIn("collector cleanup failed: ValueError: secondary cleanup failure", errors) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertEqual("partial", outcome.manifest.status.value) + + def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> None: + """A failed finalizer turns success into failure without discarding registered bytes.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' raise RuntimeError("finalizer failed after collecting evidence")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertEqual("collector cleanup failed", record.summary) + self.assertEqual(1, len(record.artifacts)) + self.assertIn("finalizer failed after collecting evidence", "\n".join(record.errors)) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + self.assertIn("artifacts/core_system_system_info/system.txt", archive.namelist()) def test_collector_timeout_preserves_independent_worker_results(self) -> None: """A timed-out worker is terminated without cancelling unrelated collection.""" @@ -1398,6 +1518,7 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: self.assertEqual("failed", records["core.system.a_timeout"].status) self.assertTrue(any("timeout" in error for error in records["core.system.a_timeout"].errors)) + self.assertEqual("timeout_exceeded", records["core.system.a_timeout"].termination_reason) self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertEqual("partial", outcome.manifest.status.value) @@ -1428,6 +1549,7 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No self.assertEqual("failed", limited.status) self.assertGreater(limited.peak_memory_bytes, 1) self.assertTrue(any("maximum_memory_bytes=1" in error for error in limited.errors)) + self.assertEqual("memory_limit_exceeded", limited.termination_reason) self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertEqual("partial", outcome.manifest.status.value) From b98cfd5a2ff067647a9354cca5d1d2029af7f403 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 14:05:43 +0300 Subject: [PATCH 137/316] fix: reject side effects during collector validation --- TODO.md | 2 +- logicytics/validation_worker.py | 58 +++++++++++++++++++++++- tests/test_core_functionality.py | 76 ++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index f2060892..79fb0a7c 100644 --- a/TODO.md +++ b/TODO.md @@ -292,7 +292,7 @@ that fails any mandatory rule is not a collector and must not be launched. - [x] Instantiate the class only after static validation succeeds. - [ ] Validate metadata, dependencies, supported platform, requested capabilities, output declarations, timeout, and size limits at runtime. -- [ ] Run a no-op contract probe against `validate()` and reject unexpected side +- [x] Run a no-op contract probe against `validate()` and reject unexpected side effects, hangs, exceptions, or malformed return values. - [ ] Produce a preflight report listing valid, quarantined, and invalid collectors, with exact file/line/rule failures. diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index 301a388d..8bbb5c4b 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -5,6 +5,7 @@ import importlib.util import inspect import json +import os import sys import tempfile from pathlib import Path @@ -41,6 +42,60 @@ def register_file( raise RuntimeError("validate() must not register artifacts") +class _ValidationSideEffectGuard: + """Reject audited mutations only while a collector validation method is active.""" + + _BLOCKED_EVENTS = { + "os.chmod", + "os.chown", + "os.link", + "os.mkdir", + "os.putenv", + "os.remove", + "os.rename", + "os.rmdir", + "os.symlink", + "os.truncate", + "os.unsetenv", + "socket.__new__", + "socket.bind", + "socket.connect", + "socket.sendto", + "subprocess.Popen", + "winreg.CreateKey", + "winreg.DeleteKey", + "winreg.DeleteValue", + "winreg.SetValue", + } + + def __init__(self) -> None: + self.active = False + + def __enter__(self) -> "_ValidationSideEffectGuard": + sys.addaudithook(self._reject_side_effect) + self.active = True + return self + + def __exit__(self, *_: object) -> None: + self.active = False + + def _reject_side_effect(self, event: str, arguments: tuple[object, ...]) -> None: + if not self.active: + return + if event == "open": + mode = arguments[1] if len(arguments) > 1 else None + flags = arguments[2] if len(arguments) > 2 else 0 + writing = isinstance(mode, str) and any(flag in mode for flag in "wax+") + writing = writing or isinstance(flags, int) and bool( + flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND) + ) + if not writing: + return + elif event not in self._BLOCKED_EVENTS: + return + raise RuntimeError(f"validate() must not perform side effects: {event}") + + def _load_module(path: Path): module_name = f"logicytics_probe_{path.stem}" spec = importlib.util.spec_from_file_location(module_name, path) @@ -98,7 +153,8 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> cancellation_file=workspace / "cancelled", ) try: - validation = collector.validate(context) + with _ValidationSideEffectGuard(): + validation = collector.validate(context) except Exception as error: raise ValueError(f"validate() probe failed: {error}") from error if not isinstance(validation, ValidationResult): diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 667bb757..3321209d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -5,6 +5,7 @@ import hashlib import json import os +import subprocess import tempfile import unittest import zipfile @@ -1705,6 +1706,81 @@ def test_preflight_rejects_malformed_validate_result(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("validate() must return ValidationResult", report.invalid[0].runtime_error or "") + def test_preflight_rejects_validation_filesystem_process_network_and_environment_side_effects(self) -> None: + """No validation probe may mutate files, launch processes, open sockets, or alter its environment.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + outside = root / "outside.marker" + source = _COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport os\nimport socket\nimport subprocess\n", + ) + attempts = ( + (f"Path({str(outside)!r}).write_text('unexpected', encoding='utf-8')", "open"), + ("(context.workspace / 'unexpected').mkdir()", "os.mkdir"), + ("subprocess.run(['python', '-c', 'pass'], check=False)", "subprocess.Popen"), + ("socket.socket()", "socket.__new__"), + ("os.environ['LOGICYTICS_PROBE_MUTATION'] = 'unexpected'", "os.putenv"), + ) + for statement, event in attempts: + with self.subTest(event=event): + collector_path.write_text( + source.replace( + " return ValidationResult(True)", + f" {statement}\n return ValidationResult(True)", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("validate() must not perform side effects", report.invalid[0].runtime_error or "") + self.assertIn(event, report.invalid[0].runtime_error or "") + self.assertFalse(outside.exists()) + + def test_preflight_rejects_validation_artifact_registration_and_exceptions(self) -> None: + """Artifact registration and ordinary validation exceptions block collection.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + attempts = ( + ("context.artifacts.register_file(context.workspace / 'evidence.txt')", "must not register artifacts"), + ("raise RuntimeError('unexpected validation failure')", "unexpected validation failure"), + ) + for statement, message in attempts: + with self.subTest(message=message): + collector_path.write_text( + _COLLECTOR.replace( + " return ValidationResult(True)", + f" {statement}\n return ValidationResult(True)", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn(message, report.invalid[0].runtime_error or "") + + def test_preflight_rejects_a_hanging_validation_worker(self) -> None: + """A timed-out validation subprocess must quarantine its collector before launch.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + with patch( + "logicytics.discovery.subprocess.run", + side_effect=subprocess.TimeoutExpired(["validation-worker"], timeout=10), + ): + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("validation worker failed", report.invalid[0].runtime_error or "") + self.assertIn("timed out", report.invalid[0].runtime_error or "") + def test_preflight_rejects_collector_print_calls(self) -> None: """Collectors must emit structured events rather than write directly to stdout.""" with tempfile.TemporaryDirectory() as temporary: From 1423cf00c8809ff9a40c454f6eba6fade10b6d10 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 14:09:25 +0300 Subject: [PATCH 138/316] fix: publish evidence packages and hashes atomically --- logicytics/packaging.py | 33 +++++++++++++++++++++++++++----- tests/test_core_functionality.py | 33 +++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 29add0d6..d0c4b630 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -165,6 +165,7 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: expected_names.update(archive_name for _, archive_name in artifact_sources) expected_names.update(archive_name for _, archive_name in log_sources) temporary_package = package_path.with_suffix(".zip.tmp") + temporary_hash = hash_path.with_suffix(".sha256.tmp") try: with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: _stream_archive_member(archive, manifest_path, "manifest.json") @@ -172,14 +173,36 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: for source, archive_name in (*artifact_sources, *log_sources): _stream_archive_member(archive, source, archive_name) _verify_archive(temporary_package, manifest, expected_names) - os.replace(temporary_package, package_path) + package_sha256 = sha256_file(temporary_package) + temporary_hash.write_text(f"{package_sha256} {package_path.name}\n", encoding="ascii") + package_backup = package_path.with_suffix(".zip.backup") + hash_backup = hash_path.with_suffix(".sha256.backup") + try: + if package_path.exists(): + os.replace(package_path, package_backup) + if hash_path.exists(): + os.replace(hash_path, hash_backup) + os.replace(temporary_package, package_path) + os.replace(temporary_hash, hash_path) + except BaseException: + if package_path.exists(): + package_path.unlink() + if hash_path.exists() and hash_backup.exists(): + hash_path.unlink() + if package_backup.exists(): + os.replace(package_backup, package_path) + if hash_backup.exists(): + os.replace(hash_backup, hash_path) + raise + if package_backup.exists(): + package_backup.unlink() + if hash_backup.exists(): + hash_backup.unlink() finally: if temporary_package.exists(): temporary_package.unlink() - temporary_hash = hash_path.with_suffix(".sha256.tmp") - package_sha256 = sha256_file(package_path) - temporary_hash.write_text(f"{package_sha256} {package_path.name}\n", encoding="ascii") - os.replace(temporary_hash, hash_path) + if temporary_hash.exists(): + temporary_hash.unlink() manifest.package = { "path": str(package_path), "sha256_path": str(hash_path), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 3321209d..da8f6319 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1014,6 +1014,37 @@ def test_package_excludes_unregistered_collector_and_engine_jsonl_files(self) -> self.assertNotIn("logs/unregistered.jsonl", archive.namelist()) self.assertNotIn("collectors/core_system_system_info/unregistered.jsonl", archive.namelist()) + def test_package_sidecar_failure_restores_existing_verified_package(self) -> None: + """A failed hash publication cannot replace or corrupt a previously verified ZIP.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + package_path = Path(outcome.manifest.package["path"]) + hash_path = Path(outcome.manifest.package["sha256_path"]) + original_package = package_path.read_bytes() + original_hash = hash_path.read_bytes() + original_replace = os.replace + + def fail_sidecar(source: str | Path, destination: str | Path) -> None: + if Path(destination) == hash_path and Path(source).suffix == ".tmp": + raise OSError("simulated sidecar publication failure") + original_replace(source, destination) + + with patch("logicytics.packaging.os.replace", side_effect=fail_sidecar): + with self.assertRaisesRegex(OSError, "sidecar publication failure"): + package_run(outcome) + self.assertEqual(original_package, package_path.read_bytes()) + self.assertEqual(original_hash, hash_path.read_bytes()) + self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) + self.assertFalse(package_path.with_suffix(".zip.backup").exists()) + self.assertFalse(hash_path.with_suffix(".sha256.tmp").exists()) + self.assertFalse(hash_path.with_suffix(".sha256.backup").exists()) + def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership(self) -> None: """A modified manifest cannot package outside files or another collector's evidence.""" with tempfile.TemporaryDirectory() as temporary: @@ -1092,7 +1123,7 @@ def test_package_streams_large_evidence_and_hashes_with_bounded_reads(self) -> N outcome = RunSupervisor(root, default_config(root)).run(plan) artifact = outcome.manifest.artifact_list()[0] source = outcome.run_directory / "artifacts" / artifact.relative_path - expected_package_path = Path(outcome.manifest.package["path"]) + expected_package_path = Path(outcome.manifest.package["path"]).with_suffix(".zip.tmp") original_open = Path.open read_sizes: list[tuple[Path, int]] = [] From fbedde75935d64565239805e6089e6b75da07a39 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:09:01 +0300 Subject: [PATCH 139/316] fix: terminate isolated collector subprocess trees --- TODO.md | 2 +- logicytics/runtime.py | 74 ++++++++++++++++++++++++++++---- tests/test_core_functionality.py | 50 +++++++++++++++++++++ 3 files changed, 117 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index 79fb0a7c..eea2b5f9 100644 --- a/TODO.md +++ b/TODO.md @@ -333,7 +333,7 @@ Windows platform allows. sensitive-file access. - [x] Require elevated privilege and explicit user approval for collectors that need it; do not grant administrator access to every plugin by default. -- [ ] Capture subprocess trees so child processes do not survive a failed collector. +- [x] Capture subprocess trees so child processes do not survive a failed collector. - [ ] Use OS/process isolation as the default boundary; treat Python-level import restrictions as validation and defense-in-depth, not as a complete sandbox. - [ ] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter diff --git a/logicytics/runtime.py b/logicytics/runtime.py index eb78e307..8a19b474 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -9,6 +9,7 @@ import multiprocessing import os import queue +import signal import shutil import subprocess import traceback @@ -95,6 +96,8 @@ def _result_from_dict(data: dict[str, object]) -> CollectorResult: def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queue) -> None: """Run a single collector in an isolated child process.""" + if os.name != "nt": + os.setsid() workspace = Path(str(payload["workspace"])) workspace.mkdir(parents=True, exist_ok=True) temporary_directory = workspace / "tmp" @@ -692,16 +695,71 @@ def _cleanup_worker_temporary_directory(worker: _ActiveWorker) -> None: except OSError: pass + @staticmethod + def _windows_descendants(parent_pid: int) -> tuple[int, ...]: + """Snapshot only descendants belonging to one isolated Windows worker.""" + class ProcessEntry(ctypes.Structure): + _fields_ = [ + ("dwSize", ctypes.c_ulong), + ("cntUsage", ctypes.c_ulong), + ("th32ProcessID", ctypes.c_ulong), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", ctypes.c_ulong), + ("cntThreads", ctypes.c_ulong), + ("th32ParentProcessID", ctypes.c_ulong), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", ctypes.c_ulong), + ("szExeFile", ctypes.c_wchar * 260), + ] + + kernel = ctypes.windll.kernel32 + snapshot = kernel.CreateToolhelp32Snapshot(0x00000002, 0) + if snapshot in (0, -1, ctypes.c_void_p(-1).value): + return () + try: + entry = ProcessEntry() + entry.dwSize = ctypes.sizeof(entry) + relationships: dict[int, list[int]] = {} + valid = kernel.Process32FirstW(snapshot, ctypes.byref(entry)) + while valid: + relationships.setdefault(int(entry.th32ParentProcessID), []).append(int(entry.th32ProcessID)) + valid = kernel.Process32NextW(snapshot, ctypes.byref(entry)) + descendants: list[int] = [] + pending = [parent_pid] + while pending: + children = relationships.get(pending.pop(), []) + descendants.extend(children) + pending.extend(children) + return tuple(reversed(descendants)) + finally: + kernel.CloseHandle(snapshot) + @staticmethod def _terminate_process_tree(process: multiprocessing.Process) -> None: - """Terminate a worker and, on Windows, any subprocesses it created.""" - if process.pid is not None and os.name == "nt": - subprocess.run( - ["taskkill", "/PID", str(process.pid), "/T", "/F"], - capture_output=True, - check=False, - text=True, - ) + """Terminate one worker-owned process tree without touching peer workers.""" + if process.pid is not None: + try: + if os.name == "nt": + descendants = RunSupervisor._windows_descendants(process.pid) + subprocess.run( + ["taskkill", "/PID", str(process.pid), "/T", "/F"], + capture_output=True, + check=False, + text=True, + timeout=5, + ) + for child_pid in descendants: + handle = ctypes.windll.kernel32.OpenProcess(0x0001 | 0x100000, False, child_pid) + if handle: + try: + ctypes.windll.kernel32.TerminateProcess(handle, 1) + ctypes.windll.kernel32.WaitForSingleObject(handle, 2000) + finally: + ctypes.windll.kernel32.CloseHandle(handle) + else: + os.killpg(process.pid, signal.SIGTERM) + except (OSError, subprocess.TimeoutExpired): + pass if process.is_alive(): process.terminate() process.join(timeout=2) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index da8f6319..49a8ab70 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import ctypes import json import os import subprocess @@ -1554,6 +1555,55 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertEqual("partial", outcome.manifest.status.value) + @unittest.skipUnless(os.name == "nt", "Windows collector subprocess-tree containment") + def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peers(self) -> None: + """A timed-out worker cannot leave its child alive or terminate independent collectors.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + source = _delayed_collector_source("a_tree", 0.0).replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nimport sys\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n timeout_seconds=2,', + ).replace( + ' output = context.workspace / "system.txt"', + ' child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])\n' + ' (context.workspace / "child.pid").write_text(str(child.pid), encoding="ascii")\n' + ' sleep(5)\n' + ' output = context.workspace / "system.txt"', + ) + (core_directory / "a_tree.py").write_text(source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + _delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + child_pid = int( + (outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text( + encoding="ascii" + ) + ) + handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, child_pid) + if handle: + try: + exit_code = ctypes.c_ulong() + self.assertTrue(ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))) + self.assertNotEqual(259, exit_code.value, "collector subprocess survived worker termination") + finally: + ctypes.windll.kernel32.CloseHandle(handle) + + self.assertEqual("failed", records["core.system.a_tree"].status) + self.assertEqual("timeout_exceeded", records["core.system.a_tree"].termination_reason) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + @unittest.skipUnless(os.name == "nt", "Windows working-set enforcement test") def test_collector_memory_limit_preserves_independent_worker_results(self) -> None: """A memory-limit violation terminates only the offending isolated worker.""" From 11942f700a273f1e6ea09cb52a99415d1cfa6aff Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:13:11 +0300 Subject: [PATCH 140/316] fix: confine collector filesystem and process mutations --- logicytics/runtime.py | 51 ++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 8a19b474..9ad736f3 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -12,6 +12,7 @@ import signal import shutil import subprocess +import sys import traceback import zipfile from dataclasses import asdict, dataclass @@ -60,6 +61,48 @@ class _ActiveWorker: exited_at: float | None = None +class _WorkerMutationGuard: + """Constrain audited collector mutations to its private workspace and evidence store.""" + + _PATH_EVENTS = {"os.mkdir", "os.remove", "os.rmdir", "os.chmod", "os.chown", "os.utime", "os.truncate"} + _DOUBLE_PATH_EVENTS = {"os.rename", "os.link", "os.symlink"} + _BLOCKED_EVENTS = {"os.chdir", "os.putenv", "os.unsetenv", "os.system"} + + def __init__(self, workspace: Path, artifact_root: Path, collector_id: str) -> None: + self.roots = (workspace.resolve(), (artifact_root / collector_id.replace(".", "_")).resolve()) + self.active = False + sys.addaudithook(self._check_event) + + def _check_path(self, value: object) -> None: + if isinstance(value, int): + return + if not isinstance(value, (str, bytes, os.PathLike)): + raise PermissionError("collector filesystem mutation has an unsupported target") + path = Path(os.fsdecode(value)).resolve() + if not any(path == root or root in path.parents for root in self.roots): + raise PermissionError(f"collector filesystem mutation escapes its private workspace: {path}") + + def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: + if not self.active: + return + if event in self._BLOCKED_EVENTS: + raise PermissionError(f"collector must not modify process state: {event}") + if event == "open": + mode = arguments[1] if len(arguments) > 1 else None + flags = arguments[2] if len(arguments) > 2 else 0 + writing = isinstance(mode, str) and any(flag in mode for flag in "wax+") + writing = writing or isinstance(flags, int) and bool( + flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND) + ) + if writing: + self._check_path(arguments[0]) + elif event in self._PATH_EVENTS: + self._check_path(arguments[0]) + elif event in self._DOUBLE_PATH_EVENTS: + self._check_path(arguments[0]) + self._check_path(arguments[1]) + + def _load_collector(path: Path, expected_class: str): module_name = f"logicytics_runtime_{uuid4().hex}" spec = importlib.util.spec_from_file_location(module_name, path) @@ -141,6 +184,12 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu result: CollectorResult | None = None lifecycle_errors: list[str] = [] failure_summary = "collector worker crashed" + mutation_guard = _WorkerMutationGuard( + workspace, + Path(str(payload["artifact_root"])), + metadata.id, + ) + mutation_guard.active = True try: validation = collector.validate(context) if not isinstance(validation, ValidationResult): @@ -174,6 +223,8 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu traceback.format_exc(), ) ) + finally: + mutation_guard.active = False if lifecycle_errors: result = CollectorResult( CollectorStatus.FAILED, diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 49a8ab70..ae411fe3 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1555,6 +1555,60 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertEqual("partial", outcome.manifest.status.value) + def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> None: + """Direct collector writes outside its private evidence roots fail without affecting peers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + attempts = ( + "context.workspace.parent / 'z_independent' / 'compromised.txt'", + f"Path({str(root / 'repository-compromised.txt')!r})", + ) + for target in attempts: + with self.subTest(target=target): + source = _delayed_collector_source("a_attacker", 0.0).replace( + ' output = context.workspace / "system.txt"', + f" ({target}).write_text('compromised', encoding='utf-8')\n" + ' output = context.workspace / "system.txt"', + ) + (core_directory / "a_attacker.py").write_text(source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + _delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + self.assertEqual("failed", records["core.system.a_attacker"].status) + self.assertIn("escapes its private workspace", "\n".join(records["core.system.a_attacker"].errors)) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertFalse((root / "repository-compromised.txt").exists()) + self.assertFalse( + (outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists() + ) + + def test_collector_cannot_mutate_its_process_environment(self) -> None: + """A collector must not change process environment or working-directory policy.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace("from pathlib import Path\n", "from pathlib import Path\nimport os\n").replace( + ' output = context.workspace / "system.txt"', + ' os.environ["LOGICYTICS_COLLECTOR_MUTATION"] = "unexpected"\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn("os.putenv", "\n".join(outcome.manifest.collectors[0].errors)) + @unittest.skipUnless(os.name == "nt", "Windows collector subprocess-tree containment") def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peers(self) -> None: """A timed-out worker cannot leave its child alive or terminate independent collectors.""" From e99a0929e55973004ca20ca026bf0ea562ffa6b1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:17:53 +0300 Subject: [PATCH 141/316] fix: enforce declared worker platform capabilities --- logicytics/runtime.py | 24 ++++++- tests/test_core_functionality.py | 109 ++++++++++++++++++++++++++++++- 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 9ad736f3..adc2ef7c 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -11,6 +11,7 @@ import queue import signal import shutil +import socket import subprocess import sys import traceback @@ -24,6 +25,7 @@ from logicytics.configuration import AppConfig from logicytics.contracts import ( Artifact, + Capability, CollectorContext, CollectorResult, CollectorStatus, @@ -68,8 +70,15 @@ class _WorkerMutationGuard: _DOUBLE_PATH_EVENTS = {"os.rename", "os.link", "os.symlink"} _BLOCKED_EVENTS = {"os.chdir", "os.putenv", "os.unsetenv", "os.system"} - def __init__(self, workspace: Path, artifact_root: Path, collector_id: str) -> None: + def __init__( + self, + workspace: Path, + artifact_root: Path, + collector_id: str, + capabilities: tuple[Capability, ...], + ) -> None: self.roots = (workspace.resolve(), (artifact_root / collector_id.replace(".", "_")).resolve()) + self.capabilities = frozenset(capabilities) self.active = False sys.addaudithook(self._check_event) @@ -85,6 +94,18 @@ def _check_path(self, value: object) -> None: def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: if not self.active: return + if event == "subprocess.Popen" and Capability.SUBPROCESS not in self.capabilities: + raise PermissionError("collector requires declared subprocess capability") + if event.startswith("socket.") and event in { + "socket.__new__", "socket.bind", "socket.connect", "socket.sendto", "socket.getaddrinfo" + }: + if Capability.NETWORK not in self.capabilities: + raise PermissionError("collector requires declared network capability") + if event == "socket.__new__" and len(arguments) > 2 and int(arguments[2]) == int(socket.SOCK_RAW): + if Capability.PACKET_CAPTURE not in self.capabilities: + raise PermissionError("collector requires declared packet_capture capability") + if event.startswith("winreg.") and Capability.REGISTRY_READ not in self.capabilities: + raise PermissionError("collector requires declared registry_read capability") if event in self._BLOCKED_EVENTS: raise PermissionError(f"collector must not modify process state: {event}") if event == "open": @@ -188,6 +209,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu workspace, Path(str(payload["artifact_root"])), metadata.id, + metadata.capabilities, ) mutation_guard.active = True try: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index ae411fe3..758dc5e8 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1609,6 +1609,101 @@ def test_collector_cannot_mutate_its_process_environment(self) -> None: self.assertEqual("failed", outcome.manifest.collectors[0].status) self.assertIn("os.putenv", "\n".join(outcome.manifest.collectors[0].errors)) + def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities(self) -> None: + """Privileged platform access must be declared in metadata, not merely imported.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + base = _COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport socket\nimport subprocess\nimport sys\nimport winreg\n", + ) + attempts = ( + ("subprocess.run([sys.executable, '-c', 'pass'], check=False)", "subprocess capability"), + ("socket.socket()", "network capability"), + ("winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Software')", "registry_read capability"), + ) + for operation, message in attempts: + with self.subTest(operation=operation): + collector_path.write_text( + base.replace( + ' output = context.workspace / "system.txt"', + f" {operation}\n output = context.workspace / \"system.txt\"", + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn(message, "\n".join(outcome.manifest.collectors[0].errors)) + + def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self) -> None: + """General network approval must not silently authorize raw packet capture.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport socket\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n capabilities=(Capability.NETWORK,),', + ).replace( + ' output = context.workspace / "system.txt"', + ' socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + approved_capabilities=(Capability.NETWORK,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn("packet_capture capability", "\n".join(outcome.manifest.collectors[0].errors)) + + def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> None: + """A declared subprocess capability remains usable after explicit request approval.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n capabilities=(Capability.SUBPROCESS,),', + ).replace( + ' output = context.workspace / "system.txt"', + ' subprocess.run([sys.executable, "-c", "pass"], check=True)\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("succeeded", outcome.manifest.collectors[0].status) + @unittest.skipUnless(os.name == "nt", "Windows collector subprocess-tree containment") def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peers(self) -> None: """A timed-out worker cannot leave its child alive or terminate independent collectors.""" @@ -1619,10 +1714,11 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer (root / "plugins").mkdir() source = _delayed_collector_source("a_tree", 0.0).replace( "from pathlib import Path\n", - "from pathlib import Path\nimport subprocess\nimport sys\n", + "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", ).replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n timeout_seconds=2,', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SUBPROCESS,),\n timeout_seconds=2,', ).replace( ' output = context.workspace / "system.txt"', ' child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])\n' @@ -1637,7 +1733,14 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer ) report = preflight(root) self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) outcome = RunSupervisor(root, default_config(root)).run(plan) records = {record.id: record for record in outcome.manifest.collectors} child_pid = int( From 493372fbfe96aa7e2c8d2b723e541444282053d0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:22:24 +0300 Subject: [PATCH 142/316] fix: enforce filesystem and sensitive evidence capabilities --- TODO.md | 2 +- logicytics/runtime.py | 33 ++++++++++++++++ tests/test_core_functionality.py | 67 ++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index eea2b5f9..1261637f 100644 --- a/TODO.md +++ b/TODO.md @@ -328,7 +328,7 @@ Windows platform allows. logger configuration, environment, or execution plan. - [x] Restrict artifact registration to paths inside the collector workspace and copy/stream only approved artifacts into the run artifact store. -- [ ] Provide declared capability gates for filesystem reads, registry reads, +- [x] Provide declared capability gates for filesystem reads, registry reads, subprocesses, network capture, raw packet access, browser-data access, and sensitive-file access. - [x] Require elevated privilege and explicit user approval for collectors that need diff --git a/logicytics/runtime.py b/logicytics/runtime.py index adc2ef7c..f1329c03 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -76,8 +76,11 @@ def __init__( artifact_root: Path, collector_id: str, capabilities: tuple[Capability, ...], + collector_source: Path, ) -> None: self.roots = (workspace.resolve(), (artifact_root / collector_id.replace(".", "_")).resolve()) + self.runtime_roots = (Path(sys.base_prefix).resolve(), Path(__file__).resolve().parent) + self.collector_source = collector_source.resolve() self.capabilities = frozenset(capabilities) self.active = False sys.addaudithook(self._check_event) @@ -91,6 +94,31 @@ def _check_path(self, value: object) -> None: if not any(path == root or root in path.parents for root in self.roots): raise PermissionError(f"collector filesystem mutation escapes its private workspace: {path}") + def _check_read(self, value: object) -> None: + if isinstance(value, int) or not isinstance(value, (str, bytes, os.PathLike)): + return + path = Path(os.fsdecode(value)).resolve() + if path == self.collector_source or any( + path == root or root in path.parents for root in (*self.roots, *self.runtime_roots) + ): + return + if Capability.FILESYSTEM_READ not in self.capabilities: + raise PermissionError("collector requires declared filesystem_read capability") + components = tuple(part.casefold() for part in path.parts) + browser_data = any(part in {"chrome", "edge", "firefox", "opera software", "opera gx"} for part in components) + private_key = ".ssh" in components or path.name.casefold() in { + "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa" + } or path.suffix.casefold() in {".pem", ".ppk"} + sensitive = private_key or browser_data or any( + label in path.name.casefold() for label in ("cookie", "credential", "password", "token", "secret", "login data") + ) + if browser_data and Capability.BROWSER_DATA not in self.capabilities: + raise PermissionError("collector requires declared browser_data capability") + if sensitive and Capability.SENSITIVE_FILES not in self.capabilities: + raise PermissionError("collector requires declared sensitive_files capability") + if private_key and Capability.PRIVATE_KEYS not in self.capabilities: + raise PermissionError("collector requires declared private_keys capability") + def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: if not self.active: return @@ -117,6 +145,10 @@ def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: ) if writing: self._check_path(arguments[0]) + else: + self._check_read(arguments[0]) + elif event in {"os.listdir", "os.scandir"} and arguments: + self._check_read(arguments[0]) elif event in self._PATH_EVENTS: self._check_path(arguments[0]) elif event in self._DOUBLE_PATH_EVENTS: @@ -210,6 +242,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu Path(str(payload["artifact_root"])), metadata.id, metadata.capabilities, + Path(str(payload["path"])), ) mutation_guard.active = True try: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 758dc5e8..6bf62bb8 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1639,6 +1639,73 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( self.assertEqual("failed", outcome.manifest.collectors[0].status) self.assertIn(message, "\n".join(outcome.manifest.collectors[0].errors)) + def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities(self) -> None: + """External evidence reads require filesystem access plus every applicable sensitive grant.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + ordinary = root / "outside.txt" + ordinary.write_text("ordinary evidence", encoding="utf-8") + browser = root / "Chrome" / "User Data" / "Default" / "Cookies" + browser.parent.mkdir(parents=True) + browser.write_text("browser evidence", encoding="utf-8") + private_key = root / ".ssh" / "id_ed25519" + private_key.parent.mkdir() + private_key.write_text("private evidence", encoding="utf-8") + cases = ( + (ordinary, (), "filesystem_read capability"), + (ordinary, (Capability.FILESYSTEM_READ,), None), + (browser, (Capability.FILESYSTEM_READ,), "browser_data capability"), + (browser, (Capability.FILESYSTEM_READ, Capability.BROWSER_DATA), "sensitive_files capability"), + ( + private_key, + (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + "private_keys capability", + ), + ( + private_key, + (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), + None, + ), + ) + for evidence, capabilities, denied in cases: + with self.subTest(evidence=evidence.name, capabilities=capabilities): + declared = ", ".join(f"Capability.{capability.name}" for capability in capabilities) + if len(capabilities) == 1: + declared += "," + collector_path.write_text( + _COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + f' supported_platforms=("win32",),\n' + f' capabilities=({declared}),', + ).replace( + ' output = context.workspace / "system.txt"', + f" Path({str(evidence)!r}).read_text(encoding='utf-8')\n" + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + approved_capabilities=capabilities, + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + if denied is None: + self.assertEqual("succeeded", record.status, record.errors) + else: + self.assertEqual("failed", record.status) + self.assertIn(denied, "\n".join(record.errors)) + def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self) -> None: """General network approval must not silently authorize raw packet capture.""" with tempfile.TemporaryDirectory() as temporary: From dd99d734bddc858a7ba3d391c1cd17ae3aaa6669 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:27:37 +0300 Subject: [PATCH 143/316] feat: persist isolated collector resource progress --- TODO.md | 2 +- logicytics/manifest.py | 10 +++++ logicytics/packaging.py | 6 +++ logicytics/runtime.py | 58 ++++++++++++++++++++++------ tests/test_core_functionality.py | 65 ++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index 1261637f..087115a8 100644 --- a/TODO.md +++ b/TODO.md @@ -434,7 +434,7 @@ Windows platform allows. itself is intentionally collected. - [ ] Never place credentials or raw sensitive evidence in console logs, exception messages, temporary filenames, or update requests. -- [ ] Record resource usage and progress per collector: files scanned, files +- [x] Record resource usage and progress per collector: files scanned, files copied, bytes written, packets observed, events processed, and elapsed time. - [ ] Make failures actionable by recording the collector ID, operation, platform error, remediation hint, and whether a retry is safe. diff --git a/logicytics/manifest.py b/logicytics/manifest.py index cf96ce44..0a114f79 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -34,6 +34,16 @@ class CollectorRecord: errors: list[str] = field(default_factory=list) artifacts: list[dict[str, Any]] = field(default_factory=list) metrics: Mapping[str, int | float | str] = field(default_factory=dict) + progress: dict[str, int | float] = field( + default_factory=lambda: { + "files_scanned": 0, + "files_copied": 0, + "bytes_written": 0, + "packets_observed": 0, + "events_processed": 0, + "elapsed_seconds": 0.0, + } + ) duration_seconds: float | None = None event_count: int = 0 last_progress_at: str | None = None diff --git a/logicytics/packaging.py b/logicytics/packaging.py index d0c4b630..a636ad93 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -38,6 +38,12 @@ def _summary(manifest: RunManifest) -> str: f" Termination: {record.termination_reason or 'not started'}", f" Attempts: {record.attempt_count}", f" Peak memory bytes: {record.peak_memory_bytes}", + f" Files scanned: {record.progress['files_scanned']}", + f" Files copied: {record.progress['files_copied']}", + f" Bytes written: {record.progress['bytes_written']}", + f" Packets observed: {record.progress['packets_observed']}", + f" Events processed: {record.progress['events_processed']}", + f" Elapsed seconds: {record.progress['elapsed_seconds']}", f" Started: {record.started_at or 'not started'}", f" Finished: {record.finished_at or 'not finished'}", f" Summary: {record.summary or 'none'}", diff --git a/logicytics/runtime.py b/logicytics/runtime.py index f1329c03..401aaf04 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -59,6 +59,7 @@ class _ActiveWorker: parallel_safe: bool reserved_output_bytes: int last_event_count: int = 0 + last_event_offset: int = 0 last_heartbeat_at: float = 0.0 exited_at: float | None = None @@ -421,6 +422,8 @@ def _write_performance_report(run_directory: Path, manifest: RunManifest) -> Non "id": record.id, "status": record.status, "duration_seconds": record.duration_seconds, + "peak_memory_bytes": record.peak_memory_bytes, + "progress": record.progress, } for record in manifest.collectors ], @@ -875,6 +878,10 @@ def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: """Persist worker liveness and any newly written structured progress events.""" changed = False now = monotonic() + elapsed = round(now - worker.started_at, 3) + if elapsed > record.progress["elapsed_seconds"]: + record.progress["elapsed_seconds"] = elapsed + changed = True if now - worker.last_heartbeat_at >= 1: record.heartbeat_at = utc_now() worker.last_heartbeat_at = now @@ -883,20 +890,43 @@ def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: if not events_path.exists(): return changed try: - events = events_path.read_text(encoding="utf-8").splitlines() + with events_path.open("r", encoding="utf-8") as stream: + stream.seek(worker.last_event_offset) + events: list[str] = [] + while line := stream.readline(): + if not line.endswith("\n"): + break + events.append(line) + worker.last_event_offset = stream.tell() except OSError: return changed - if len(events) <= worker.last_event_count: - return changed - worker.last_event_count = len(events) - record.event_count = len(events) - try: - event = json.loads(events[-1]) - except json.JSONDecodeError: + if not events: return changed - timestamp = event.get("at") - if isinstance(timestamp, str): - record.last_progress_at = timestamp + worker.last_event_count += len(events) + record.event_count = worker.last_event_count + aliases = { + "files_scanned": ("files_scanned", "scanned_files", "entry_count"), + "files_copied": ("files_copied", "copied_files"), + "bytes_written": ("bytes_written",), + "packets_observed": ("packets_observed", "observation_count", "packet_count"), + "events_processed": ("events_processed", "event_count"), + } + for line in events: + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + timestamp = event.get("at") + if isinstance(timestamp, str): + record.last_progress_at = timestamp + fields = event.get("fields", {}) + if not isinstance(fields, dict): + continue + for name, alternatives in aliases.items(): + for alternative in alternatives: + value = fields.get(alternative) + if isinstance(value, (int, float)) and not isinstance(value, bool) and value >= 0: + record.progress[name] = max(record.progress[name], value) changed = True return changed @@ -904,6 +934,12 @@ def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) -> None: """Attach result, elapsed time, and progress accounting to one record.""" record.apply_result(result, duration_seconds=round(monotonic() - worker.started_at, 3)) + record.progress["bytes_written"] = max( + record.progress["bytes_written"], + sum(artifact.size_bytes for artifact in result.artifacts), + ) + record.progress["files_copied"] = max(record.progress["files_copied"], len(result.artifacts)) + record.progress["elapsed_seconds"] = max(record.progress["elapsed_seconds"], record.duration_seconds or 0.0) record.worker_pid = worker.process.pid record.worker_exit_code = worker.process.exitcode if record.termination_reason is None: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 6bf62bb8..b1d7644e 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -956,6 +956,71 @@ def test_performance_report_is_finalized_before_automatic_packaging(self) -> Non with self.assertRaisesRegex(FileNotFoundError, "performance report"): package_run(outcome) + def test_collector_resource_progress_persists_in_manifest_summary_and_performance_report(self) -> None: + """Incremental progress fields remain visible live and in every packaged diagnostic.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n").replace( + ' output = context.workspace / "system.txt"', + ' context.report_progress("scan_started", scanned_files=3)\n' + ' sleep(0.2)\n' + ' context.report_progress(\n' + ' "scan_finished", scanned_files=9, copied_files=4, bytes_written=25,\n' + ' observation_count=6, event_count=11,\n' + ' )\n' + ' sleep(0.2)\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest(max_workers=1, acknowledge_authorization=True, performance_check=True), + ) + snapshots: list[dict[str, object]] = [] + from logicytics.manifest import write_manifest as original_write_manifest + + def capture_manifest(path: Path, manifest: object) -> None: + snapshots.append(json.loads(json.dumps(manifest.to_dict()))) + original_write_manifest(path, manifest) + + with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest): + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + expected = { + "files_scanned": 9, + "files_copied": 4, + "bytes_written": 25, + "packets_observed": 6, + "events_processed": 11, + } + for field, value in expected.items(): + self.assertEqual(value, record.progress[field]) + self.assertGreater(record.progress["elapsed_seconds"], 0) + self.assertTrue( + any( + snapshot["collectors"][0]["status"] == "running" + and snapshot["collectors"][0]["progress"]["files_scanned"] >= 3 + for snapshot in snapshots + ) + ) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] + report = json.loads(archive.read("logs/performance.json"))["collectors"][0] + summary = archive.read("summary.txt").decode("utf-8") + self.assertEqual(record.progress, packaged_record["progress"]) + self.assertEqual(record.progress, report["progress"]) + self.assertEqual(record.peak_memory_bytes, report["peak_memory_bytes"]) + self.assertIn("Files scanned: 9", summary) + self.assertIn("Files copied: 4", summary) + self.assertIn("Bytes written: 25", summary) + self.assertIn("Packets observed: 6", summary) + self.assertIn("Events processed: 11", summary) + def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self) -> None: """Only manifest artifacts may enter a package, and their final bytes must match.""" with tempfile.TemporaryDirectory() as temporary: From 241e001d9d0da1ecad568f37ba4ce8dd567fe9bb Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:33:38 +0300 Subject: [PATCH 144/316] feat: provide actionable collector failure diagnostics --- TODO.md | 2 +- logicytics/manifest.py | 39 ++++++++++++++++++++++++++++++++ logicytics/packaging.py | 9 ++++++++ logicytics/runtime.py | 2 ++ tests/test_core_functionality.py | 35 ++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 087115a8..10c985ba 100644 --- a/TODO.md +++ b/TODO.md @@ -436,7 +436,7 @@ Windows platform allows. messages, temporary filenames, or update requests. - [x] Record resource usage and progress per collector: files scanned, files copied, bytes written, packets observed, events processed, and elapsed time. -- [ ] Make failures actionable by recording the collector ID, operation, platform +- [x] Make failures actionable by recording the collector ID, operation, platform error, remediation hint, and whether a retry is safe. ### Make concurrency a planned capability diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 0a114f79..9d8937ef 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -32,6 +32,7 @@ class CollectorRecord: finished_at: str | None = None summary: str | None = None errors: list[str] = field(default_factory=list) + failure: dict[str, str | bool] | None = None artifacts: list[dict[str, Any]] = field(default_factory=list) metrics: Mapping[str, int | float | str] = field(default_factory=dict) progress: dict[str, int | float] = field( @@ -63,9 +64,47 @@ def apply_result(self, result: CollectorResult, duration_seconds: float | None = self.errors = [redact_text(error) for error in result.errors] self.artifacts = [artifact.to_dict() for artifact in result.artifacts] self.metrics = redact_mapping(result.metrics) + self.failure = self._failure_details() if self.status == "failed" else None self.duration_seconds = duration_seconds self.finished_at = utc_now() + def _failure_details(self) -> dict[str, str | bool]: + """Convert collector-specific failures into actionable, redacted guidance.""" + platform_error = self.errors[0] if self.errors else self.summary or "unknown collector failure" + diagnostic = f"{self.summary or ''} {platform_error}".casefold() + operation = "collect" + remediation = "Inspect the collector traceback and correct the reported platform failure." + retry_safe = not self.artifacts + if "timeout" in diagnostic: + operation = "collect" + remediation = "Reduce the collection scope or increase the collector timeout before retrying." + elif "memory" in diagnostic or "working set" in diagnostic: + operation = "collect" + remediation = "Reduce the collection scope or increase its declared memory limit." + elif "cleanup" in diagnostic or "finaliz" in diagnostic: + operation = "cleanup" + remediation = "Inspect the private collector workspace and repair its cleanup prerequisites." + retry_safe = False + elif "permission" in diagnostic or "capability" in diagnostic or "access is denied" in diagnostic: + operation = "access" + remediation = "Declare and explicitly approve the required capability or run with authorized privileges." + retry_safe = False + elif "artifact" in diagnostic or "output limit" in diagnostic: + operation = "artifact_registration" + remediation = "Reduce evidence size/count or adjust the collector's declared artifact limits." + retry_safe = False + elif "validat" in diagnostic: + operation = "validate" + remediation = "Correct the collector configuration and platform prerequisites before retrying." + retry_safe = False + return { + "collector_id": self.id, + "operation": operation, + "platform_error": platform_error, + "remediation": remediation, + "retry_safe": retry_safe, + } + @dataclass(slots=True) class RunManifest: diff --git a/logicytics/packaging.py b/logicytics/packaging.py index a636ad93..59e76adf 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -49,6 +49,15 @@ def _summary(manifest: RunManifest) -> str: f" Summary: {record.summary or 'none'}", ] ) + if record.failure is not None: + lines.extend( + [ + f" Failed operation: {record.failure['operation']}", + f" Platform error: {record.failure['platform_error']}", + f" Remediation: {record.failure['remediation']}", + f" Retry safe: {str(record.failure['retry_safe']).lower()}", + ] + ) if record.errors: lines.append(" Reasons:") lines.extend(f" - {' '.join(error.splitlines())}" for error in record.errors) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 401aaf04..7f9693bc 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -761,6 +761,7 @@ def _schedule_retry( "finished_at": record.finished_at, "summary": record.summary, "errors": list(record.errors), + "failure": dict(record.failure) if record.failure is not None else None, "duration_seconds": record.duration_seconds, "worker_pid": record.worker_pid, "worker_exit_code": record.worker_exit_code, @@ -770,6 +771,7 @@ def _schedule_retry( record.status = "retry_pending" record.summary = "collector retry scheduled" record.errors = [] + record.failure = None record.artifacts = [] record.started_at = None record.finished_at = None diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index b1d7644e..fa6b755b 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -916,7 +916,14 @@ def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) - outcome = RunSupervisor(root, default_config(root)).run(plan) self.assertEqual("failed", outcome.manifest.collectors[0].status) self.assertIn("RuntimeError", "\n".join(outcome.manifest.collectors[0].errors)) + failure = outcome.manifest.collectors[0].failure + self.assertIsNotNone(failure) + self.assertNotIn("crash-password", failure["platform_error"]) + self.assertNotIn("crash-token", failure["platform_error"]) + self.assertIn("[REDACTED]", failure["platform_error"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] + self.assertEqual(failure, packaged_record["failure"]) diagnostics = "\n".join( archive.read(name).decode("utf-8") for name in archive.namelist() @@ -1408,8 +1415,11 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N record = outcome.manifest.collectors[0] self.assertEqual("succeeded", record.status, record.errors) + self.assertIsNone(record.failure) self.assertEqual(2, record.attempt_count) self.assertEqual(1, len(record.retry_history)) + self.assertEqual("collect", record.retry_history[0]["failure"]["operation"]) + self.assertTrue(record.retry_history[0]["failure"]["retry_safe"]) self.assertTrue( any("temporary collector failure" in error for error in record.retry_history[0]["errors"]) ) @@ -1423,6 +1433,8 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N summary = archive.read("summary.txt").decode("utf-8") self.assertEqual(2, packaged_record["attempt_count"]) self.assertEqual(1, len(packaged_record["retry_history"])) + self.assertIsNone(packaged_record["failure"]) + self.assertTrue(packaged_record["retry_history"][0]["failure"]["retry_safe"]) self.assertIn("Attempts: 2", summary) def test_retry_policy_stops_after_declared_attempt_limit(self) -> None: @@ -1478,8 +1490,18 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info" / "system.txt").is_file()) self.assertEqual(1, len(record.artifacts)) self.assertEqual(1, len(outcome.manifest.artifact_list())) + self.assertEqual("core.system.system_info", record.failure["collector_id"]) + self.assertEqual("collect", record.failure["operation"]) + self.assertIn("failure after evidence registration", record.failure["platform_error"]) + self.assertFalse(record.failure["retry_safe"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: self.assertIn("artifacts/core_system_system_info/system.txt", archive.namelist()) + packaged = json.loads(archive.read("manifest.json"))["collectors"][0] + summary = archive.read("summary.txt").decode("utf-8") + self.assertEqual(record.failure, packaged["failure"]) + self.assertIn("Failed operation: collect", summary) + self.assertIn("Retry safe: false", summary) + self.assertIn("Remediation:", summary) def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results(self) -> None: """A failed process finalizes once, preserves evidence, and cannot stop its neighbor.""" @@ -1589,6 +1611,9 @@ def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> No self.assertEqual("collector cleanup failed", record.summary) self.assertEqual(1, len(record.artifacts)) self.assertIn("finalizer failed after collecting evidence", "\n".join(record.errors)) + self.assertEqual("cleanup", record.failure["operation"]) + self.assertIn("cleanup", record.failure["remediation"]) + self.assertFalse(record.failure["retry_safe"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: self.assertIn("artifacts/core_system_system_info/system.txt", archive.namelist()) @@ -1617,7 +1642,10 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: self.assertEqual("failed", records["core.system.a_timeout"].status) self.assertTrue(any("timeout" in error for error in records["core.system.a_timeout"].errors)) self.assertEqual("timeout_exceeded", records["core.system.a_timeout"].termination_reason) + self.assertTrue(records["core.system.a_timeout"].failure["retry_safe"]) + self.assertIn("timeout", records["core.system.a_timeout"].failure["remediation"]) self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertIsNone(records["core.system.z_independent"].failure) self.assertEqual("partial", outcome.manifest.status.value) def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> None: @@ -1703,6 +1731,10 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( outcome = RunSupervisor(root, default_config(root)).run(plan) self.assertEqual("failed", outcome.manifest.collectors[0].status) self.assertIn(message, "\n".join(outcome.manifest.collectors[0].errors)) + failure = outcome.manifest.collectors[0].failure + self.assertEqual("access", failure["operation"]) + self.assertIn("capability", failure["remediation"]) + self.assertFalse(failure["retry_safe"]) def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities(self) -> None: """External evidence reads require filesystem access plus every applicable sensitive grant.""" @@ -1921,6 +1953,9 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No self.assertGreater(limited.peak_memory_bytes, 1) self.assertTrue(any("maximum_memory_bytes=1" in error for error in limited.errors)) self.assertEqual("memory_limit_exceeded", limited.termination_reason) + self.assertEqual("collect", limited.failure["operation"]) + self.assertIn("memory limit", limited.failure["remediation"]) + self.assertTrue(limited.failure["retry_safe"]) self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertEqual("partial", outcome.manifest.status.value) From 9e3972ac4dc1c5e9297f02a105fa7cfcd86f81bf Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:39:56 +0300 Subject: [PATCH 145/316] feat: coordinate isolated collector resource classes --- TODO.md | 2 +- logicytics/__init__.py | 2 + logicytics/contracts.py | 15 ++++ logicytics/runtime.py | 15 ++++ tests/test_core_functionality.py | 123 ++++++++++++++++++++++++++++++- 5 files changed, 155 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 10c985ba..4494a1c2 100644 --- a/TODO.md +++ b/TODO.md @@ -443,7 +443,7 @@ Windows platform allows. - [ ] Use a scheduler with bounded workers instead of creating independent thread pools inside collectors. -- [ ] Declare dependencies and resource classes so disk-heavy, network-heavy, +- [x] Declare dependencies and resource classes so disk-heavy, network-heavy, registry-sensitive, and interactive collectors can be coordinated. - [ ] Prevent concurrent collectors from writing the same artifact path. - [ ] Use thread-safe artifact registration and structured event reporting. diff --git a/logicytics/__init__.py b/logicytics/__init__.py index ec8422bf..8b851d99 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -9,6 +9,7 @@ CollectorResult, CoreCollector, PluginCollector, + ResourceClass, RunRequest, Specialty, ValidationResult, @@ -23,6 +24,7 @@ "CollectorResult", "CoreCollector", "PluginCollector", + "ResourceClass", "RunRequest", "Specialty", "ValidationResult", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index d10365de..8bc8c3f5 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -85,6 +85,16 @@ class Capability(StrEnum): ELEVATED_PRIVILEGES = "elevated_privileges" +class ResourceClass(StrEnum): + """Scheduling resources whose conflicting collectors cannot safely overlap.""" + + GENERAL = "general" + DISK_HEAVY = "disk_heavy" + NETWORK_HEAVY = "network_heavy" + REGISTRY_SENSITIVE = "registry_sensitive" + INTERACTIVE = "interactive" + + @dataclass(frozen=True, slots=True) class CollectorMetadata: """Declarative identity, limits, and permissions for one collector.""" @@ -109,6 +119,7 @@ class CollectorMetadata: retry_delay_seconds: float = 0.0 minimum_contract_version: str = CONTRACT_VERSION parallel_safe: bool = True + resource_class: ResourceClass = ResourceClass.GENERAL def __post_init__(self) -> None: """Reject malformed metadata before it can enter planning or runtime.""" @@ -164,6 +175,8 @@ def __post_init__(self) -> None: raise ValueError("metadata retry_delay_seconds must be a number from 0 to 30") if not isinstance(self.parallel_safe, bool): raise ValueError("metadata parallel_safe must be boolean") + if not isinstance(self.resource_class, ResourceClass): + raise ValueError("metadata resource_class must be a ResourceClass value") @staticmethod def _validate_labels(name: str, values: tuple[str, ...], *, require_value: bool = False) -> None: @@ -179,6 +192,7 @@ def to_dict(self) -> dict[str, Any]: data = asdict(self) data["specialty"] = self.specialty.value if isinstance(self.specialty, Specialty) else self.specialty data["capabilities"] = [capability.value for capability in self.capabilities] + data["resource_class"] = self.resource_class.value return data @classmethod @@ -194,6 +208,7 @@ def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = Fa raise ValueError("collector specialty is unsupported") values["specialty"] = specialty values["capabilities"] = tuple(Capability(capability) for capability in values.get("capabilities", ())) + values["resource_class"] = ResourceClass(values.get("resource_class", ResourceClass.GENERAL)) for field_name in ( "supported_platforms", "sensitive_data_categories", diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 7f9693bc..9a382ece 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -29,6 +29,7 @@ CollectorContext, CollectorResult, CollectorStatus, + ResourceClass, RunStatus, ValidationResult, ) @@ -57,6 +58,7 @@ class _ActiveWorker: maximum_memory_bytes: int workspace: Path parallel_safe: bool + resource_class: ResourceClass reserved_output_bytes: int last_event_count: int = 0 last_event_offset: int = 0 @@ -504,6 +506,18 @@ def _supervise( break if active and not candidate.metadata.parallel_safe: break + if active and ( + candidate.metadata.resource_class is ResourceClass.INTERACTIVE + or any(worker.resource_class is ResourceClass.INTERACTIVE for worker in active.values()) + or ( + candidate.metadata.resource_class is not ResourceClass.GENERAL + and any( + worker.resource_class is candidate.metadata.resource_class + for worker in active.values() + ) + ) + ): + break reserved_output_bytes = sum(worker.reserved_output_bytes for worker in active.values()) remaining_output_bytes = ( self.configuration.runtime.maximum_run_output_bytes @@ -562,6 +576,7 @@ def _supervise( candidate.metadata.maximum_memory_bytes, workspace, candidate.metadata.parallel_safe, + candidate.metadata.resource_class, output_budget, ) run_logger.event("info", "collector_started", collector_id=candidate.metadata.id) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index fa6b755b..dae2cdbb 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -22,7 +22,7 @@ from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config -from logicytics.contracts import Capability, CollectorMetadata, RunRequest, Specialty +from logicytics.contracts import Capability, CollectorMetadata, ResourceClass, RunRequest, Specialty from logicytics.discovery import preflight from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError @@ -77,6 +77,7 @@ def _delayed_collector_source( *, parallel_safe: bool = True, dependencies: tuple[str, ...] = (), + resource_class: ResourceClass = ResourceClass.GENERAL, fail: bool = False, ) -> str: """Create a valid fixture collector with observable scheduling duration.""" @@ -84,10 +85,15 @@ def _delayed_collector_source( source = _COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") source = source.replace("core.system.system_info", f"core.system.{filename}") source = source.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n") + source = source.replace( + "from logicytics import CollectorMetadata,", + "from logicytics import CollectorMetadata, ResourceClass,", + ) source = source.replace( ' supported_platforms=("win32",),', ' supported_platforms=("win32",),\n' f' dependencies={dependencies!r},\n' + f' resource_class=ResourceClass.{resource_class.name},\n' f' parallel_safe={parallel_safe!r},', ) source = source.replace( @@ -153,6 +159,10 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "maximum_artifact_bytes": True}) with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): CollectorMetadata(**{**common, "maximum_output_bytes": 8, "maximum_artifact_bytes": 9}) + with self.assertRaisesRegex(ValueError, "resource_class"): + CollectorMetadata(**{**common, "resource_class": "disk_heavy"}) + with self.assertRaisesRegex(ValueError, "ResourceClass"): + CollectorMetadata.from_dict({**CollectorMetadata(**common).to_dict(), "resource_class": "unknown"}) with self.assertRaisesRegex(ValueError, "sensitive collectors"): CollectorMetadata(**{**common, "sensitive_data_categories": ("credentials",)}) for retries in (-1, 4, True): @@ -165,6 +175,10 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "retry_delay_seconds": retry_delay}) metadata = CollectorMetadata(**{**common, "maximum_output_bytes": 8}) self.assertEqual(8, metadata.maximum_artifact_bytes) + self.assertIs(ResourceClass.GENERAL, metadata.resource_class) + disk_metadata = CollectorMetadata(**{**common, "resource_class": ResourceClass.DISK_HEAVY}) + self.assertEqual("disk_heavy", disk_metadata.to_dict()["resource_class"]) + self.assertIs(ResourceClass.DISK_HEAVY, CollectorMetadata.from_dict(disk_metadata.to_dict()).resource_class) def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) -> None: """Default collection excludes sensitive evidence unless explicitly selected.""" @@ -211,6 +225,26 @@ def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) - ) self.assertEqual([sensitive_id], [item.metadata.id for item in deep.collectors]) + def test_preflight_rejects_unregistered_collector_resource_classes(self) -> None: + """Invalid scheduling metadata blocks shipped collectors before any run starts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n resource_class="disk_heavy",', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("resource_class", report.invalid[0].runtime_error) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest()) + def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: """A shipped collector cannot silently introduce sensitive default evidence.""" with tempfile.TemporaryDirectory() as temporary: @@ -1293,6 +1327,93 @@ def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: self.assertLessEqual(first.finished_at, serial.started_at) self.assertLessEqual(serial.finished_at, last.started_at) + def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: + """Disk, network, and registry resource conflicts each serialize their owners.""" + for resource_class in ( + ResourceClass.DISK_HEAVY, + ResourceClass.NETWORK_HEAVY, + ResourceClass.REGISTRY_SENSITIVE, + ): + with self.subTest(resource_class=resource_class), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "z_second"): + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, 0.15, resource_class=resource_class), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + first = records["core.system.a_first"] + second = records["core.system.z_second"] + + self.assertEqual("succeeded", first.status, first.errors) + self.assertEqual("succeeded", second.status, second.errors) + self.assertLessEqual(first.finished_at, second.started_at) + + def test_independent_resource_classes_preserve_bounded_parallelism(self) -> None: + """Different noninteractive resource owners can overlap in isolated workers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + fixtures = ( + ("a_disk", ResourceClass.DISK_HEAVY), + ("z_network", ResourceClass.NETWORK_HEAVY), + ) + for filename, resource_class in fixtures: + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, 0.35, resource_class=resource_class), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("succeeded", records["core.system.a_disk"].status) + self.assertEqual("succeeded", records["core.system.z_network"].status) + self.assertLess(records["core.system.z_network"].started_at, records["core.system.a_disk"].finished_at) + + def test_interactive_resource_class_runs_without_any_worker_overlap(self) -> None: + """Interactive collectors require an exclusive deterministic scheduler window.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + fixtures = ( + ("a_disk", ResourceClass.DISK_HEAVY), + ("m_interactive", ResourceClass.INTERACTIVE), + ("z_general", ResourceClass.GENERAL), + ) + for filename, resource_class in fixtures: + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, 0.15, resource_class=resource_class), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=3, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertLessEqual( + records["core.system.a_disk"].finished_at, + records["core.system.m_interactive"].started_at, + ) + self.assertLessEqual( + records["core.system.m_interactive"].finished_at, + records["core.system.z_general"].started_at, + ) + def test_dependencies_finish_before_dependents_start(self) -> None: """Topological order must become an execution barrier under bounded parallelism.""" with tempfile.TemporaryDirectory() as temporary: From aabdee033654239106d07443c7fa56381aad96ac Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:44:13 +0300 Subject: [PATCH 146/316] fix: synchronize artifact registration and event reporting --- TODO.md | 4 +- logicytics/artifacts.py | 16 +++++++- logicytics/logging.py | 7 +++- tests/test_core_functionality.py | 65 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 4494a1c2..f89a7961 100644 --- a/TODO.md +++ b/TODO.md @@ -445,8 +445,8 @@ Windows platform allows. pools inside collectors. - [x] Declare dependencies and resource classes so disk-heavy, network-heavy, registry-sensitive, and interactive collectors can be coordinated. -- [ ] Prevent concurrent collectors from writing the same artifact path. -- [ ] Use thread-safe artifact registration and structured event reporting. +- [x] Prevent concurrent collectors from writing the same artifact path. +- [x] Use thread-safe artifact registration and structured event reporting. - [x] Preserve deterministic manifest ordering even when collectors finish out of order. - [x] Define which collectors may run in parallel and which must run serially. diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index 7c0214e8..f880920a 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -7,6 +7,7 @@ import shutil from datetime import datetime, timezone from pathlib import Path +from threading import RLock from uuid import uuid4 from logicytics.contracts import Artifact, ArtifactWriter @@ -74,13 +75,15 @@ def __init__( ): raise ArtifactError("run_output_budget_bytes must be a positive integer") self._cancellation_file = cancellation_file + self._registration_lock = RLock() self._bytes_registered = 0 self._artifacts: list[Artifact] = [] @property def artifacts(self) -> tuple[Artifact, ...]: """Return artifacts registered by this worker in registration order.""" - return tuple(self._artifacts) + with self._registration_lock: + return tuple(self._artifacts) def register_file( self, @@ -88,6 +91,17 @@ def register_file( *, media_type: str = "application/octet-stream", transformations: tuple[str, ...] = (), + ) -> Artifact: + """Serialize destination allocation, quota checks, publication, and catalog updates.""" + with self._registration_lock: + return self._register_file(source, media_type=media_type, transformations=transformations) + + def _register_file( + self, + source: Path, + *, + media_type: str, + transformations: tuple[str, ...], ) -> Artifact: if not isinstance(transformations, tuple) or any( not isinstance(step, str) or not step.strip() for step in transformations diff --git a/logicytics/logging.py b/logicytics/logging.py index b4d8c0f1..4f9fa7b4 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -6,6 +6,7 @@ import traceback from datetime import datetime, timezone from pathlib import Path +from threading import RLock from time import perf_counter from typing import Callable, ParamSpec, TypeVar @@ -23,6 +24,7 @@ def __init__(self, path: Path, *, run_id: str, collector_id: str | None = None) self.path = path self.run_id = run_id self.collector_id = collector_id + self._event_lock = RLock() self.path.parent.mkdir(parents=True, exist_ok=True) def event(self, level: str, message: str, **fields: int | float | str) -> None: @@ -37,8 +39,9 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: payload["collector_id"] = self.collector_id if fields: payload["fields"] = redact_mapping(fields) - with self.path.open("a", encoding="utf-8") as stream: - stream.write(json.dumps(payload, sort_keys=True) + "\n") + with self._event_lock: + with self.path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(payload, sort_keys=True) + "\n") def timed(logger: EventLogger, *, level: str = "info") -> Callable[ diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index dae2cdbb..d66281da 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -10,6 +10,7 @@ import tempfile import unittest import zipfile +from concurrent.futures import ThreadPoolExecutor from datetime import datetime from pathlib import Path from unittest.mock import patch @@ -355,6 +356,23 @@ def test_structured_event_logger_redacts_secret_fields_and_inline_credentials(se self.assertEqual("[REDACTED]", event["fields"]["api_key"]) self.assertEqual("safe", event["fields"]["ordinary"]) + def test_structured_event_logger_serializes_concurrent_jsonl_events(self) -> None: + """Concurrent diagnostics stay complete, independently parseable, and redacted.""" + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "events.jsonl" + logger = FileEventLogger(path, run_id="concurrent-run", collector_id="core.system.test") + + def write_event(index: int) -> None: + logger.event("info", "collector_progress", sequence=index, password=f"secret-{index}") + + with ThreadPoolExecutor(max_workers=12) as executor: + list(executor.map(write_event, range(120))) + events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + self.assertEqual(120, len(events)) + self.assertEqual(set(range(120)), {event["fields"]["sequence"] for event in events}) + self.assertTrue(all(event["fields"]["password"] == "[REDACTED]" for event in events)) + def test_file_listing_filters_and_normalizes_files(self) -> None: """Recursive file discovery must filter extensions and excluded directories.""" with tempfile.TemporaryDirectory() as temporary: @@ -656,6 +674,53 @@ def test_artifact_registration_enforces_collector_file_count_limit(self) -> None with self.assertRaisesRegex(ArtifactError, "maximum_artifact_files"): writer.register_file(second, media_type="text/plain") + def test_concurrent_artifact_registration_allocates_unique_owned_paths(self) -> None: + """Simultaneous registration of one source cannot overwrite another catalog entry.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "shared.txt" + source.write_text("evidence", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 20) + + with ThreadPoolExecutor(max_workers=10) as executor: + artifacts = list(executor.map(lambda _: writer.register_file(source), range(20))) + + paths = [artifact.relative_path for artifact in artifacts] + self.assertEqual(20, len(set(paths))) + self.assertEqual(20, len(writer.artifacts)) + self.assertTrue(all((artifact_root / path).read_text(encoding="utf-8") == "evidence" for path in paths)) + + def test_concurrent_artifact_registration_cannot_bypass_file_or_byte_limits(self) -> None: + """Parallel callers observe one atomic quota and cannot overfill evidence storage.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "shared.txt" + source.write_text("123", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 6, 2) + + def register(_: int) -> bool: + try: + writer.register_file(source) + except ArtifactError: + return False + return True + + with ThreadPoolExecutor(max_workers=12) as executor: + outcomes = list(executor.map(register, range(24))) + + self.assertEqual(2, sum(outcomes)) + self.assertEqual(2, len(writer.artifacts)) + self.assertEqual(6, sum(artifact.size_bytes for artifact in writer.artifacts)) + self.assertEqual(2, len(list((artifact_root / "core_system_test").iterdir()))) + def test_artifact_registration_enforces_individual_file_size_limit(self) -> None: """A collector-specific file ceiling rejects large evidence before it is copied.""" with tempfile.TemporaryDirectory() as temporary: From 1ea1e7c4d9000e2484522f8f5d38ed42fe375ad8 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:49:05 +0300 Subject: [PATCH 147/316] feat: expose explicit sequential and parallel run modes --- TODO.md | 2 +- logicytics/cli.py | 29 ++++++++++++-- tests/test_core_functionality.py | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/TODO.md b/TODO.md index f89a7961..68c926ca 100644 --- a/TODO.md +++ b/TODO.md @@ -450,7 +450,7 @@ Windows platform allows. - [x] Preserve deterministic manifest ordering even when collectors finish out of order. - [x] Define which collectors may run in parallel and which must run serially. -- [ ] Provide a sequential mode for debugging and a bounded parallel mode for +- [x] Provide a sequential mode for debugging and a bounded parallel mode for normal use; do not make parallelism a hidden behavior of a particular flag. ### Make configuration and profiles understandable diff --git a/logicytics/cli.py b/logicytics/cli.py index c6fb4673..ca6650a6 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -29,16 +29,28 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: profile = "minimal" if getattr(arguments, "minimal", False) else "deep" if getattr(arguments, "depth", False) else "standard" if getattr( arguments, "default_mode", False) or getattr(arguments, "threaded", False) else arguments.profile + sequential = getattr(arguments, "sequential", False) + parallel = getattr(arguments, "parallel", False) + performance_check = getattr(arguments, "performance_check", False) + default_mode = getattr(arguments, "default_mode", False) + if sequential and arguments.workers is not None and arguments.workers != 1: + raise ValueError("sequential execution requires --workers=1") + if parallel and (performance_check or default_mode): + raise ValueError("parallel execution conflicts with sequential performance/default mode") + if sequential and getattr(arguments, "threaded", False): + raise ValueError("sequential execution conflicts with legacy --threaded mode") + worker_count = 1 if sequential or performance_check or default_mode else arguments.workers or default_workers + if parallel and worker_count < 2: + raise ValueError("parallel execution requires at least two configured workers") return RunRequest( profile=profile, include=tuple(arguments.include), exclude=tuple(arguments.exclude), enable_plugins=arguments.plugins, - max_workers=1 if getattr(arguments, "performance_check", False) or getattr(arguments, "default_mode", - False) else arguments.workers or default_workers, + max_workers=worker_count, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), - performance_check=getattr(arguments, "performance_check", False), + performance_check=performance_check, ) @@ -61,6 +73,17 @@ def _parser() -> argparse.ArgumentParser: help="Approve an access capability requested by the selected collectors.", ) if command == "run": + execution = subparser.add_mutually_exclusive_group() + execution.add_argument( + "--sequential", + action="store_true", + help="Explicitly run isolated collectors one at a time for deterministic debugging.", + ) + execution.add_argument( + "--parallel", + action="store_true", + help="Explicitly run isolated collectors with the configured bounded worker limit.", + ) mode = subparser.add_mutually_exclusive_group() mode.add_argument("--default", dest="default_mode", action="store_true", help="Run the standard built-in profile.") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d66281da..3cb2d562 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -529,6 +529,40 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) + def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: + """Execution policy is selectable directly instead of relying on compatibility modes.""" + parser = _parser() + sequential = _request(parser.parse_args(["run", "--sequential"]), default_workers=4) + parallel = _request(parser.parse_args(["run", "--parallel"]), default_workers=4) + bounded_parallel = _request( + parser.parse_args(["run", "--parallel", "--workers", "3"]), + default_workers=4, + ) + + self.assertEqual(1, sequential.max_workers) + self.assertEqual(4, parallel.max_workers) + self.assertEqual(3, bounded_parallel.max_workers) + self.assertEqual("standard", sequential.profile) + self.assertEqual("standard", parallel.profile) + + def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: + """Contradictory worker policies fail before creating a collection plan.""" + parser = _parser() + conflicts = ( + (["run", "--sequential", "--workers", "2"], 4, "sequential execution"), + (["run", "--sequential", "--threaded"], 4, "legacy --threaded"), + (["run", "--parallel", "--performance-check"], 4, "performance/default"), + (["run", "--parallel", "--default"], 4, "performance/default"), + (["run", "--parallel", "--workers", "1"], 4, "at least two"), + (["run", "--parallel"], 1, "at least two"), + ) + for arguments, default_workers, error in conflicts: + with self.subTest(arguments=arguments, default_workers=default_workers): + with self.assertRaisesRegex(ValueError, error): + _request(parser.parse_args(arguments), default_workers=default_workers) + with patch("sys.stderr"), self.assertRaises(SystemExit): + parser.parse_args(["run", "--sequential", "--parallel"]) + """Validate the foundation before real core collectors are added.""" def test_artifacts_cannot_escape_collector_workspace(self) -> None: @@ -1360,6 +1394,37 @@ def test_parallel_completion_preserves_deterministic_manifest_order(self) -> Non self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) self.assertLess(records["core.system.z_fast"].finished_at, records["core.system.a_slow"].finished_at) + def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: + """First-class CLI policies determine real sequential versus bounded worker overlap.""" + for execution_mode, expect_overlap in (("--sequential", False), ("--parallel", True)): + with self.subTest(execution_mode=execution_mode), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "z_second"): + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, 0.3), + encoding="utf-8", + ) + arguments = _parser().parse_args( + ["run", execution_mode, "--acknowledge-authorization"] + ) + request = _request(arguments, default_workers=2) + report = preflight(root) + self.assertEqual((), report.invalid) + outcome = RunSupervisor(root, default_config(root)).run(build_plan(report, request)) + records = {record.id: record for record in outcome.manifest.collectors} + first = records["core.system.a_first"] + second = records["core.system.z_second"] + + self.assertEqual("succeeded", first.status, first.errors) + self.assertEqual("succeeded", second.status, second.errors) + if expect_overlap: + self.assertLess(second.started_at, first.finished_at) + else: + self.assertLessEqual(first.finished_at, second.started_at) + def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: """A collector declaring parallel_safe=False must run alone between bounded workers.""" with tempfile.TemporaryDirectory() as temporary: From 47d19528feffb0d3c11436acd1e6ee15c562a830 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 15:54:01 +0300 Subject: [PATCH 148/316] feat: persist complete run manifest lifecycle and provenance --- TODO.md | 8 +++--- logicytics/manifest.py | 12 ++++++++ logicytics/packaging.py | 4 +++ logicytics/runtime.py | 1 + tests/test_core_functionality.py | 48 +++++++++++++++++++++++++++++++- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/TODO.md b/TODO.md index 68c926ca..a8da04b3 100644 --- a/TODO.md +++ b/TODO.md @@ -377,13 +377,13 @@ Windows platform allows. ### Define the v4.0 run model -- [ ] Create a unique run directory before collection starts: +- [x] Create a unique run directory before collection starts: `ACCESS/RUNS//`. -- [ ] Store raw collector output under a controlled artifact tree rather than in +- [x] Store raw collector output under a controlled artifact tree rather than in the source tree or project root. -- [ ] Create a run manifest at the beginning, update it as collectors start and +- [x] Create a run manifest at the beginning, update it as collectors start and finish, and finalize it even after cancellation or partial failure. -- [ ] Record requested profile, resolved collector plan, Logicytics version, +- [x] Record requested profile, resolved collector plan, Logicytics version, configuration snapshot with secrets removed, host metadata, privilege state, start/end times, cancellation state, errors, skipped items, and artifact checksums. diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 9d8937ef..30489563 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -117,9 +117,13 @@ class RunManifest: configuration: Mapping[str, Any] collectors: list[CollectorRecord] host: Mapping[str, str] + resolved_plan: tuple[str, ...] = () engine_version: str = CONTRACT_VERSION action: str = "run" finished_at: str | None = None + cancellation_requested: bool = False + errors: list[dict[str, str]] = field(default_factory=list) + skipped_collectors: list[str] = field(default_factory=list) package: Mapping[str, str] | None = None total_artifact_bytes: int = 0 @@ -140,6 +144,7 @@ def create( configuration=configuration, collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], + resolved_plan=tuple(collector_id for collector_id, _ in collector_sources), host={ "platform": sys_platform(), "hostname": platform.node(), @@ -160,6 +165,13 @@ def finalize_status(self) -> None: self.status = RunStatus.PARTIAL else: self.status = RunStatus.SUCCEEDED + self.cancellation_requested = "cancelled" in statuses + self.skipped_collectors = [record.id for record in self.collectors if record.status == "skipped"] + self.errors = [ + {"collector_id": record.id, "status": record.status, "message": error} + for record in self.collectors + for error in record.errors + ] self.finished_at = utc_now() self.total_artifact_bytes = sum(artifact.size_bytes for artifact in self.artifact_list()) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 59e76adf..3bed7b70 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -22,8 +22,12 @@ def _summary(manifest: RunManifest) -> str: lines = [ f"Logicytics run: {manifest.run_id}", f"Status: {manifest.status.value}", + f"Cancellation requested: {str(manifest.cancellation_requested).lower()}", f"Requested: {manifest.requested_at}", f"Finished: {manifest.finished_at or 'not finalized'}", + f"Resolved collectors: {len(manifest.resolved_plan)}", + f"Skipped collectors: {len(manifest.skipped_collectors)}", + f"Errors: {len(manifest.errors)}", "", "Collectors:", ] diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 9a382ece..6c965efc 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -371,6 +371,7 @@ def run(self, plan: RunPlan) -> RunOutcome: self.configuration.to_manifest_dict(), [(candidate.metadata.id, candidate.path) for candidate in plan.collectors if candidate.metadata], ) + write_manifest(manifest_path, manifest) manifest.status = RunStatus.RUNNING write_manifest(manifest_path, manifest) run_logger.event("info", "run_started", collectors=len(plan.collectors)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 3cb2d562..509498db 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -21,6 +21,7 @@ from logicytics.cli import _parser, _request from logicytics.file_listing import list_files from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed +from logicytics.manifest import write_manifest from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config from logicytics.contracts import Capability, CollectorMetadata, ResourceClass, RunRequest, Specialty @@ -930,8 +931,26 @@ def test_preflight_plan_run_and_package(self) -> None: RunRequest(profile="standard", max_workers=1, acknowledge_authorization=True), ) configuration = default_config(root) - outcome = RunSupervisor(root, configuration).run(plan) + persisted_states: list[str] = [] + + def capture_manifest_state(path, manifest): + persisted_states.append(manifest.status.value) + return write_manifest(path, manifest) + + with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest_state): + outcome = RunSupervisor(root, configuration).run(plan) + self.assertEqual("planned", persisted_states[0]) + self.assertEqual("running", persisted_states[1]) + self.assertEqual("succeeded", persisted_states[-1]) self.assertEqual("succeeded", outcome.manifest.status.value) + self.assertEqual(("core.system.system_info",), outcome.manifest.resolved_plan) + self.assertFalse(outcome.manifest.cancellation_requested) + self.assertEqual([], outcome.manifest.errors) + self.assertEqual([], outcome.manifest.skipped_collectors) + self.assertEqual(configuration.runtime.output_root, outcome.run_directory.parent) + self.assertTrue(outcome.run_directory.is_absolute()) + self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info").is_dir()) + self.assertFalse((root / "system.txt").exists()) self.assertEqual(1, len(outcome.manifest.artifact_list())) self.assertEqual("run", outcome.manifest.action) self.assertEqual("4.0", outcome.manifest.engine_version) @@ -964,6 +983,10 @@ def test_preflight_plan_run_and_package(self) -> None: datetime.fromisoformat(artifact.collected_at) self.assertEqual(("copied into run artifact store",), artifact.transformations) packaged_manifest = json.loads(archive.read("manifest.json")) + self.assertEqual(["core.system.system_info"], packaged_manifest["resolved_plan"]) + self.assertFalse(packaged_manifest["cancellation_requested"]) + self.assertEqual([], packaged_manifest["errors"]) + self.assertEqual([], packaged_manifest["skipped_collectors"]) packaged_artifact = packaged_manifest["collectors"][0]["artifacts"][0] self.assertEqual(artifact.source_category, packaged_artifact["source_category"]) self.assertEqual(artifact.collected_at, packaged_artifact["collected_at"]) @@ -971,9 +994,15 @@ def test_preflight_plan_run_and_package(self) -> None: record = outcome.manifest.collectors[0] summary = archive.read("summary.txt").decode("utf-8") self.assertIn(f"Status: {record.status}", summary) + self.assertIn("Cancellation requested: false", summary) + self.assertIn("Resolved collectors: 1", summary) self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) + repeated = RunSupervisor(root, configuration).run(plan) + self.assertNotEqual(outcome.manifest.run_id, repeated.manifest.run_id) + self.assertNotEqual(outcome.run_directory, repeated.run_directory) + def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" with tempfile.TemporaryDirectory() as temporary: @@ -1049,6 +1078,9 @@ def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) - outcome = RunSupervisor(root, default_config(root)).run(plan) self.assertEqual("failed", outcome.manifest.collectors[0].status) self.assertIn("RuntimeError", "\n".join(outcome.manifest.collectors[0].errors)) + self.assertEqual("core.system.system_info", outcome.manifest.errors[0]["collector_id"]) + self.assertNotIn("crash-password", outcome.manifest.errors[0]["message"]) + self.assertNotIn("crash-token", outcome.manifest.errors[0]["message"]) failure = outcome.manifest.collectors[0].failure self.assertIsNotNone(failure) self.assertNotIn("crash-password", failure["platform_error"]) @@ -1600,6 +1632,16 @@ def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: self.assertEqual("skipped", dependent.status) self.assertIsNone(dependent.started_at) self.assertTrue(any(dependency_id in error for error in dependent.errors)) + self.assertEqual(["core.system.b_dependent"], outcome.manifest.skipped_collectors) + self.assertEqual( + {dependency_id, "core.system.b_dependent"}, + {error["collector_id"] for error in outcome.manifest.errors}, + ) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged = json.loads(archive.read("manifest.json")) + summary = archive.read("summary.txt").decode("utf-8") + self.assertEqual(["core.system.b_dependent"], packaged["skipped_collectors"]) + self.assertIn("Skipped collectors: 1", summary) def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: """An isolated collector crash must produce a durable failed run outcome.""" @@ -2224,6 +2266,7 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: with patch.object(supervisor, "_supervise", side_effect=KeyboardInterrupt): outcome = supervisor.run(plan) self.assertEqual("cancelled", outcome.manifest.status.value) + self.assertTrue(outcome.manifest.cancellation_requested) record = outcome.manifest.collectors[0] self.assertEqual("cancelled", record.status) self.assertEqual("run cancelled by user", record.summary) @@ -2233,7 +2276,10 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: self.assertTrue(package_path.is_file()) with zipfile.ZipFile(package_path) as archive: summary = archive.read("summary.txt").decode("utf-8") + packaged_manifest = json.loads(archive.read("manifest.json")) + self.assertTrue(packaged_manifest["cancellation_requested"]) self.assertIn("Status: cancelled", summary) + self.assertIn("Cancellation requested: true", summary) self.assertIn("Summary: run cancelled by user", summary) self.assertIn("Started: not started", summary) self.assertIn(f"Finished: {record.finished_at}", summary) From fe3c14398d33831e982d8a1cd70950e14adf2862 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 16:13:21 +0300 Subject: [PATCH 149/316] feat: expose structured preflight quarantine diagnostics --- TODO.md | 2 +- logicytics/cli.py | 12 ++-- logicytics/discovery.py | 120 ++++++++++++++++++++++++++++++- tests/test_core_functionality.py | 60 +++++++++++++++- 4 files changed, 185 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index a8da04b3..c0e24cda 100644 --- a/TODO.md +++ b/TODO.md @@ -294,7 +294,7 @@ that fails any mandatory rule is not a collector and must not be launched. output declarations, timeout, and size limits at runtime. - [x] Run a no-op contract probe against `validate()` and reject unexpected side effects, hangs, exceptions, or malformed return values. -- [ ] Produce a preflight report listing valid, quarantined, and invalid collectors, +- [x] Produce a preflight report listing valid, quarantined, and invalid collectors, with exact file/line/rule failures. - [x] Refuse to launch before collection if a core collector or explicitly selected plugin fails validation. diff --git a/logicytics/cli.py b/logicytics/cli.py index ca6650a6..9633b9dc 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -120,17 +120,17 @@ def main(argv: list[str] | None = None) -> int: configuration = load_config(root, arguments.config) report = preflight(root) if arguments.command == "preflight": + validation = report.to_dict( + selected_plugins=tuple(arguments.include), + enable_plugins=arguments.plugins, + ) payload = { "environment": inspect_environment().to_dict(), "sysinternals": ensure_sysinternals(root).to_dict(), - "valid": [candidate.metadata.id for candidate in report.valid if candidate.metadata], - "invalid": [ - {"path": str(candidate.path), "errors": candidate.static_errors or [candidate.runtime_error]} - for candidate in report.invalid - ], + **validation, } print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if not report.invalid else 2 + return 0 if not validation["invalid"] else 2 if arguments.command == "debug": payload = { "configuration": configuration.to_manifest_dict(), diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 5d357145..5ab70660 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -9,13 +9,25 @@ import subprocess import sys import tempfile -from dataclasses import dataclass, field +from dataclasses import asdict, dataclass, field from pathlib import Path from logicytics.contracts import CONTRACT_VERSION, CollectorKind, CollectorMetadata _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} + + +@dataclass(frozen=True, slots=True) +class ValidationDiagnostic: + """One stable, source-addressable collector validation failure.""" + + path: str + line: int + rule: str + message: str + + @dataclass(slots=True) class CollectorCandidate: """A file that may be a runnable collector.""" @@ -32,6 +44,30 @@ def valid(self) -> bool: """Whether static and runtime validation both passed.""" return not self.static_errors and self.metadata is not None and self.runtime_error is None + @property + def selection_id(self) -> str: + """Return the ID used to explicitly select this path even when metadata is invalid.""" + if self.metadata is not None: + return self.metadata.id + owner = self.path.parent.name if self.path.name == "main.py" else self.path.stem + return f"{self.kind.value}.{owner}" + + @property + def diagnostics(self) -> tuple[ValidationDiagnostic, ...]: + """Normalize free-form validator details into exact source diagnostics.""" + messages = [*self.static_errors] + if self.runtime_error: + messages.append(self.runtime_error) + return tuple( + ValidationDiagnostic( + path=str(self.path), + line=_diagnostic_line(self.path, message), + rule=_diagnostic_rule(message, runtime=index >= len(self.static_errors)), + message=message, + ) + for index, message in enumerate(messages) + ) + @dataclass(frozen=True, slots=True) class PreflightReport: @@ -47,6 +83,88 @@ def valid(self) -> tuple[CollectorCandidate, ...]: def invalid(self) -> tuple[CollectorCandidate, ...]: return tuple(candidate for candidate in self.candidates if not candidate.valid) + def to_dict( + self, + *, + selected_plugins: tuple[str, ...] = (), + enable_plugins: bool = False, + ) -> dict[str, list[dict[str, object]]]: + """Classify invalid plugins as quarantined unless the request selects them.""" + valid = [ + {"id": candidate.metadata.id, "kind": candidate.kind.value, "path": str(candidate.path)} + for candidate in self.valid + if candidate.metadata is not None + ] + invalid: list[dict[str, object]] = [] + quarantined: list[dict[str, object]] = [] + selected = set(selected_plugins) + for candidate in self.invalid: + item = { + "id": candidate.selection_id, + "kind": candidate.kind.value, + "path": str(candidate.path), + "diagnostics": [asdict(diagnostic) for diagnostic in candidate.diagnostics], + } + if candidate.kind is CollectorKind.CORE or enable_plugins or candidate.selection_id in selected: + invalid.append(item) + else: + quarantined.append(item) + return {"valid": valid, "quarantined": quarantined, "invalid": invalid} + + +def _diagnostic_rule(message: str, *, runtime: bool) -> str: + """Return a stable machine-readable rule for one validator message.""" + if runtime: + return "runtime.contract" + normalized = message.casefold() + rules = ( + ("filename", "static.filename"), + ("docstring", "static.docstring"), + ("print", "static.console_output"), + ("import-time", "static.import_time_side_effect"), + ("top-level", "static.top_level_statement"), + ("inherit", "static.inheritance"), + ("collector class", "static.class_name"), + ("return", "static.return_annotation"), + ("parameter", "static.method_signature"), + ("classmethod", "static.method_signature"), + ("method", "static.method_contract"), + ) + return next((rule for marker, rule in rules if marker in normalized), "static.module_contract") + + +def _diagnostic_line(path: Path, message: str) -> int: + """Locate the exact source line implicated by a normalized validation message.""" + explicit = re.search(r"\(line (\d+)\)", message) + if explicit: + return int(explicit.group(1)) + traceback_lines = re.findall(r'File "([^"]+)", line (\d+)', message) + resolved = path.resolve() + for source, line in reversed(traceback_lines): + try: + if Path(source).resolve() == resolved: + return int(line) + except OSError: + continue + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + except (OSError, UnicodeDecodeError, SyntaxError) as error: + return max(1, int(getattr(error, "lineno", 1) or 1)) + method_match = re.match(r"(metadata|validate|collect|cleanup|estimate|dependencies)\b", message) + if method_match: + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == method_match.group(1): + return node.lineno + if "print" in message: + for node in ast.walk(tree): + if isinstance(node, ast.Call) and _top_level_call_name(node) == "print": + return node.lineno + if "class" in message or "inherit" in message: + collector_class = next((node for node in tree.body if isinstance(node, ast.ClassDef)), None) + if collector_class is not None: + return collector_class.lineno + return 1 + def _pascal_case(filename: str) -> str: return "".join(part.capitalize() for part in Path(filename).stem.split("_")) + "Collector" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 509498db..19d83c69 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -4,6 +4,7 @@ import hashlib import ctypes +import io import json import os import subprocess @@ -18,7 +19,7 @@ from logicytics import packaging from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command -from logicytics.cli import _parser, _request +from logicytics.cli import _parser, _request, main from logicytics.file_listing import list_files from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed from logicytics.manifest import write_manifest @@ -244,6 +245,11 @@ def test_preflight_rejects_unregistered_collector_resource_classes(self) -> None report = preflight(root) self.assertEqual(1, len(report.invalid)) self.assertIn("resource_class", report.invalid[0].runtime_error) + diagnostic = report.invalid[0].diagnostics[0] + self.assertEqual("runtime.contract", diagnostic.rule) + self.assertEqual(str(collector_path), diagnostic.path) + self.assertGreater(diagnostic.line, 1) + self.assertIn("resource_class", diagnostic.message) with self.assertRaises(PreflightError): build_plan(report, RunRequest()) @@ -2308,12 +2314,52 @@ def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: plugin_path.parent.mkdir() plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") report = preflight(root) + payload = report.to_dict() + self.assertEqual(["core.system.system_info"], [item["id"] for item in payload["valid"]]) + self.assertEqual([], payload["invalid"]) + self.assertEqual(["plugin.broken_plugin"], [item["id"] for item in payload["quarantined"]]) + diagnostic = payload["quarantined"][0]["diagnostics"][0] + self.assertEqual(str(plugin_path), diagnostic["path"]) + self.assertEqual(1, diagnostic["line"]) + self.assertEqual("static.class_name", diagnostic["rule"]) + self.assertIn("exactly one public collector class", diagnostic["message"]) + selected = report.to_dict(selected_plugins=("plugin.broken_plugin",)) + self.assertEqual([], selected["quarantined"]) + self.assertEqual(["plugin.broken_plugin"], [item["id"] for item in selected["invalid"]]) + enabled = report.to_dict(enable_plugins=True) + self.assertEqual(["plugin.broken_plugin"], [item["id"] for item in enabled["invalid"]]) plan = build_plan(report, RunRequest()) self.assertEqual( ["core.system.system_info"], [candidate.metadata.id for candidate in plan.collectors], ) + def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(self) -> None: + """The public preflight report exposes actionable diagnostics and fail-closed selection.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text(_COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "broken_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + + for arguments, expected_exit, key in ( + (["preflight"], 0, "quarantined"), + (["preflight", "--include", "plugin.broken_plugin"], 2, "invalid"), + (["preflight", "--plugins"], 2, "invalid"), + ): + with self.subTest(arguments=arguments): + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + exit_code = main(arguments) + payload = json.loads(output.getvalue()) + self.assertEqual(expected_exit, exit_code) + self.assertEqual("plugin.broken_plugin", payload[key][0]["id"]) + self.assertEqual(str(plugin_path), payload[key][0]["diagnostics"][0]["path"]) + self.assertIn("rule", payload[key][0]["diagnostics"][0]) + def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: """Lifecycle annotations must match the strict collector contract exactly.""" with tempfile.TemporaryDirectory() as temporary: @@ -2328,6 +2374,18 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: report = preflight(root) self.assertEqual(1, len(report.invalid)) self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) + diagnostic = next( + item for item in report.invalid[0].diagnostics + if item.message == "collect must return CollectorResult" + ) + source_lines = collector_path.read_text(encoding="utf-8").splitlines() + expected_line = next( + index for index, line in enumerate(source_lines, start=1) + if "def collect(self, context: CollectorContext) -> ValidationResult:" in line + ) + self.assertEqual(expected_line, diagnostic.line) + self.assertEqual("static.return_annotation", diagnostic.rule) + self.assertEqual(str(collector_path), diagnostic.path) def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: """Optional estimates use the same strict typed contract as lifecycle methods.""" From b8dd2ce5b9fbeb8f56493522e593390e22eb2a7e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 16:18:26 +0300 Subject: [PATCH 150/316] feat: enforce typed collector outcomes and partial packages --- TODO.md | 4 +- logicytics/contracts.py | 61 ++++++++++++++++++++++++ tests/test_core_functionality.py | 82 +++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index c0e24cda..99c71bee 100644 --- a/TODO.md +++ b/TODO.md @@ -389,9 +389,9 @@ Windows platform allows. checksums. - [ ] Give every collector an explicit lifecycle: validate, prepare, collect, finalize, report result. -- [ ] Make collector results explicit: `succeeded`, `partial`, `skipped`, +- [x] Make collector results explicit: `succeeded`, `partial`, `skipped`, `cancelled`, or `failed`. -- [ ] Allow partial runs to be packaged and clearly labeled instead of appearing +- [x] Allow partial runs to be packaged and clearly labeled instead of appearing successful or being silently discarded. - [ ] Make reruns selectable by collector ID and keep rerun artifacts separate from the original run. diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 8bc8c3f5..908eba57 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -272,10 +272,71 @@ class CollectorResult: errors: tuple[str, ...] = () metrics: Mapping[str, int | float | str] = field(default_factory=dict) + def __post_init__(self) -> None: + """Reject malformed terminal results before they can cross a worker boundary.""" + if not isinstance(self.status, CollectorStatus): + raise ValueError("collector result status must be a CollectorStatus value") + if not isinstance(self.summary, str) or not self.summary.strip(): + raise ValueError("collector result summary must be a non-empty string") + if not isinstance(self.artifacts, tuple) or not all(isinstance(item, Artifact) for item in self.artifacts): + raise ValueError("collector result artifacts must be a tuple of Artifact values") + artifact_ids = tuple(item.id for item in self.artifacts) + artifact_paths = tuple(item.relative_path for item in self.artifacts) + if len(set(artifact_ids)) != len(artifact_ids) or len(set(artifact_paths)) != len(artifact_paths): + raise ValueError("collector result artifacts must have unique IDs and paths") + if not isinstance(self.errors, tuple) or not all( + isinstance(error, str) and error.strip() for error in self.errors + ): + raise ValueError("collector result errors must be a tuple of non-empty strings") + if not isinstance(self.metrics, Mapping) or not all( + isinstance(name, str) and name.strip() + and isinstance(value, (int, float, str)) + and not isinstance(value, bool) + and (not isinstance(value, float) or isfinite(value)) + for name, value in self.metrics.items() + ): + raise ValueError("collector result metrics must contain finite scalar values") + @classmethod def succeeded(cls, summary: str, artifacts: tuple[Artifact, ...] = ()) -> "CollectorResult": return cls(CollectorStatus.SUCCEEDED, summary, artifacts) + @classmethod + def partial( + cls, + summary: str, + artifacts: tuple[Artifact, ...] = (), + *, + errors: tuple[str, ...] = (), + ) -> "CollectorResult": + """Return an explicitly incomplete result while preserving registered evidence.""" + return cls(CollectorStatus.PARTIAL, summary, artifacts, errors=errors) + + @classmethod + def skipped(cls, summary: str, *, errors: tuple[str, ...] = ()) -> "CollectorResult": + """Return an explicit prerequisite or policy skip.""" + return cls(CollectorStatus.SKIPPED, summary, errors=errors) + + @classmethod + def cancelled( + cls, + summary: str, + artifacts: tuple[Artifact, ...] = (), + ) -> "CollectorResult": + """Return explicit cancellation while retaining already registered evidence.""" + return cls(CollectorStatus.CANCELLED, summary, artifacts) + + @classmethod + def failed( + cls, + summary: str, + *, + errors: tuple[str, ...] = (), + artifacts: tuple[Artifact, ...] = (), + ) -> "CollectorResult": + """Return explicit failure while retaining already registered evidence.""" + return cls(CollectorStatus.FAILED, summary, artifacts, errors=errors) + @dataclass(frozen=True, slots=True) class RunRequest: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 19d83c69..222385fe 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -25,7 +25,15 @@ from logicytics.manifest import write_manifest from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config -from logicytics.contracts import Capability, CollectorMetadata, ResourceClass, RunRequest, Specialty +from logicytics.contracts import ( + Capability, + CollectorMetadata, + CollectorResult, + CollectorStatus, + ResourceClass, + RunRequest, + Specialty, +) from logicytics.discovery import preflight from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError @@ -183,6 +191,35 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: self.assertEqual("disk_heavy", disk_metadata.to_dict()["resource_class"]) self.assertIs(ResourceClass.DISK_HEAVY, CollectorMetadata.from_dict(disk_metadata.to_dict()).resource_class) + def test_collector_results_expose_all_strict_typed_terminal_states(self) -> None: + """Every terminal collector outcome has an explicit, validated result constructor.""" + outcomes = ( + (CollectorResult.succeeded("complete"), CollectorStatus.SUCCEEDED), + (CollectorResult.partial("incomplete", errors=("one source unavailable",)), CollectorStatus.PARTIAL), + (CollectorResult.skipped("prerequisite unavailable"), CollectorStatus.SKIPPED), + (CollectorResult.cancelled("operator cancelled"), CollectorStatus.CANCELLED), + (CollectorResult.failed("collection failed", errors=("access denied",)), CollectorStatus.FAILED), + ) + for result, status in outcomes: + with self.subTest(status=status): + self.assertIs(status, result.status) + invalid = ( + ({"status": "succeeded", "summary": "complete"}, "status"), + ({"status": CollectorStatus.SUCCEEDED, "summary": ""}, "summary"), + ({"status": CollectorStatus.SUCCEEDED, "summary": "complete", "artifacts": []}, "artifacts"), + ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ["failure"]}, "errors"), + ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ("",)}, "errors"), + ({"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": True}}, "metrics"), + ( + {"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": float("inf")}}, + "metrics", + ), + ) + for options, message in invalid: + with self.subTest(options=options): + with self.assertRaisesRegex(ValueError, message): + CollectorResult(**options) + def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) -> None: """Default collection excludes sensitive evidence unless explicitly selected.""" with tempfile.TemporaryDirectory() as temporary: @@ -1009,6 +1046,49 @@ def capture_manifest_state(path, manifest): self.assertNotEqual(outcome.manifest.run_id, repeated.manifest.run_id) self.assertNotEqual(outcome.run_directory, repeated.run_directory) + def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> None: + """Partial terminal outcomes preserve evidence without masquerading as success.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' return CollectorResult.partial(\n' + ' "only one evidence source was available",\n' + ' (artifact,),\n' + ' errors=("secondary evidence source was unavailable",),\n' + ' )', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + outcome = RunSupervisor(root, default_config(root)).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + ) + record = outcome.manifest.collectors[0] + + self.assertEqual("partial", outcome.manifest.status.value) + self.assertEqual("partial", record.status) + self.assertEqual(1, len(record.artifacts)) + self.assertIn("secondary evidence source", outcome.manifest.errors[0]["message"]) + package_path = Path(outcome.manifest.package["path"]) + self.assertTrue(package_path.is_file()) + with zipfile.ZipFile(package_path) as archive: + packaged = json.loads(archive.read("manifest.json")) + summary = archive.read("summary.txt").decode("utf-8") + self.assertEqual( + ["ok"], + archive.read("artifacts/core_system_system_info/system.txt").decode("utf-8").splitlines(), + ) + self.assertEqual("partial", packaged["status"]) + self.assertEqual("partial", packaged["collectors"][0]["status"]) + self.assertIn("Status: partial", summary) + self.assertIn("secondary evidence source was unavailable", summary) + def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" with tempfile.TemporaryDirectory() as temporary: From 81e4f57c3a355b6a6ba140f54580417dd7de3d91 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 16:23:49 +0300 Subject: [PATCH 151/316] feat: add isolated collector-scoped evidence reruns --- TODO.md | 2 +- logicytics/cli.py | 26 +++++++++ logicytics/contracts.py | 8 +++ logicytics/manifest.py | 5 ++ logicytics/packaging.py | 2 + logicytics/planner.py | 2 + logicytics/runtime.py | 1 + tests/test_core_functionality.py | 94 ++++++++++++++++++++++++++++++++ 8 files changed, 139 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index 99c71bee..ec0f97d2 100644 --- a/TODO.md +++ b/TODO.md @@ -393,7 +393,7 @@ Windows platform allows. `cancelled`, or `failed`. - [x] Allow partial runs to be packaged and clearly labeled instead of appearing successful or being silently discarded. -- [ ] Make reruns selectable by collector ID and keep rerun artifacts separate from +- [x] Make reruns selectable by collector ID and keep rerun artifacts separate from the original run. - [ ] Make the run plan deterministic: the same request and configuration produce the same collector set and dependency order. diff --git a/logicytics/cli.py b/logicytics/cli.py index 9633b9dc..f172b08c 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -42,6 +42,26 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: worker_count = 1 if sequential or performance_check or default_mode else arguments.workers or default_workers if parallel and worker_count < 2: raise ValueError("parallel execution requires at least two configured workers") + parent_run_id: str | None = None + if rerun_path := getattr(arguments, "rerun_from", None): + if not arguments.include: + raise ValueError("--rerun-from requires at least one explicit --include collector ID") + manifest_path = rerun_path / "manifest.json" if rerun_path.is_dir() else rerun_path + try: + previous = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError(f"original run manifest cannot be loaded: {error}") from error + if not isinstance(previous, dict) or not isinstance(previous.get("run_id"), str): + raise ValueError("original run manifest must contain a valid run_id") + if previous.get("status") not in {"succeeded", "partial", "failed", "cancelled"}: + raise ValueError("original run manifest must describe a finalized run") + resolved = previous.get("resolved_plan") + if not isinstance(resolved, list) or not all(isinstance(item, str) for item in resolved): + raise ValueError("original run manifest must contain a valid resolved_plan") + unknown = sorted(set(arguments.include) - set(resolved)) + if unknown: + raise ValueError(f"rerun collectors were not present in the original run: {', '.join(unknown)}") + parent_run_id = previous["run_id"] return RunRequest( profile=profile, include=tuple(arguments.include), @@ -51,6 +71,7 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), performance_check=performance_check, + rerun_from=parent_run_id, ) @@ -73,6 +94,11 @@ def _parser() -> argparse.ArgumentParser: help="Approve an access capability requested by the selected collectors.", ) if command == "run": + subparser.add_argument( + "--rerun-from", + type=Path, + help="Rerun explicitly included collector IDs from a finalized run manifest or directory.", + ) execution = subparser.add_mutually_exclusive_group() execution.add_argument( "--sequential", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 908eba57..090db06b 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -16,6 +16,7 @@ _SEMANTIC_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") _CONTRACT_VERSION = re.compile(r"^\d+\.\d+$") _LABEL = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_RUN_ID = re.compile(r"^run-[0-9a-f]{32}$") class CollectorKind(StrEnum): @@ -350,6 +351,7 @@ class RunRequest: acknowledge_authorization: bool = False approved_capabilities: tuple[Capability, ...] = () performance_check: bool = False + rerun_from: str | None = None def __post_init__(self) -> None: """Reject malformed selections and execution policy before planning.""" @@ -366,6 +368,12 @@ def __post_init__(self) -> None: raise ValueError(f"request {name} must not contain duplicate collector IDs") if set(self.include).intersection(self.exclude): raise ValueError("request include and exclude selections must not overlap") + if self.rerun_from is not None and ( + not isinstance(self.rerun_from, str) or not _RUN_ID.fullmatch(self.rerun_from) + ): + raise ValueError("request rerun_from must be a valid original run ID") + if self.rerun_from is not None and not self.include: + raise ValueError("request rerun_from requires explicit included collector IDs") for name in ("enable_plugins", "acknowledge_authorization", "performance_check"): if not isinstance(getattr(self, name), bool): raise ValueError(f"request {name} must be boolean") diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 30489563..bd267fbd 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -120,6 +120,7 @@ class RunManifest: resolved_plan: tuple[str, ...] = () engine_version: str = CONTRACT_VERSION action: str = "run" + parent_run_id: str | None = None finished_at: str | None = None cancellation_requested: bool = False errors: list[dict[str, str]] = field(default_factory=list) @@ -134,6 +135,8 @@ def create( request: Mapping[str, Any], configuration: Mapping[str, Any], collector_sources: list[tuple[str, Path]], + *, + parent_run_id: str | None = None, ) -> "RunManifest": """Create the initial planned manifest before collection begins.""" return cls( @@ -145,6 +148,8 @@ def create( collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], resolved_plan=tuple(collector_id for collector_id, _ in collector_sources), + action="rerun" if parent_run_id is not None else "run", + parent_run_id=parent_run_id, host={ "platform": sys_platform(), "hostname": platform.node(), diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 3bed7b70..03690233 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -21,6 +21,8 @@ def _summary(manifest: RunManifest) -> str: lines = [ f"Logicytics run: {manifest.run_id}", + f"Action: {manifest.action}", + f"Parent run: {manifest.parent_run_id or 'none'}", f"Status: {manifest.status.value}", f"Cancellation requested: {str(manifest.cancellation_requested).lower()}", f"Requested: {manifest.requested_at}", diff --git a/logicytics/planner.py b/logicytics/planner.py index 232f41ab..2bf27b3e 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -26,6 +26,8 @@ def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> return False if metadata.id in request.include: return True + if request.rerun_from is not None: + return False if candidate.kind is CollectorKind.PLUGIN and not request.enable_plugins: return False return request.profile in metadata.default_profiles diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 6c965efc..e3e4dc65 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -370,6 +370,7 @@ def run(self, plan: RunPlan) -> RunOutcome: asdict(plan.request), self.configuration.to_manifest_dict(), [(candidate.metadata.id, candidate.path) for candidate in plan.collectors if candidate.metadata], + parent_run_id=plan.request.rerun_from, ) write_manifest(manifest_path, manifest) manifest.status = RunStatus.RUNNING diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 222385fe..828c82ea 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -512,6 +512,8 @@ def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> Non ({"performance_check": True, "max_workers": 2}, "performance_check"), ({"max_workers": True}, "max_workers"), ({"max_workers": 65}, "max_workers"), + ({"rerun_from": "../outside", "include": (collector_id,)}, "rerun_from"), + ({"rerun_from": "run-" + "a" * 32}, "explicit included"), ({"approved_capabilities": ("filesystem_read",)}, "approved_capabilities"), ( {"approved_capabilities": (Capability.FILESYSTEM_READ, Capability.FILESYSTEM_READ)}, @@ -607,6 +609,42 @@ def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: with patch("sys.stderr"), self.assertRaises(SystemExit): parser.parse_args(["run", "--sequential", "--parallel"]) + def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work(self) -> None: + """Reruns require a finalized authentic-shaped manifest and explicit original collector IDs.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + manifest_path = root / "manifest.json" + collector_id = "core.system.system_info" + valid_manifest = { + "run_id": "run-" + "a" * 32, + "status": "succeeded", + "resolved_plan": [collector_id], + } + manifest_path.write_text(json.dumps(valid_manifest), encoding="utf-8") + parser = _parser() + + with self.assertRaisesRegex(ValueError, "explicit --include"): + _request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) + with self.assertRaisesRegex(ValueError, "not present in the original"): + _request( + parser.parse_args( + ["run", "--rerun-from", str(manifest_path), "--include", "core.system.other"] + ), + 2, + ) + manifest_path.write_text(json.dumps({**valid_manifest, "status": "running"}), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "finalized"): + _request( + parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), + 2, + ) + manifest_path.write_text("not json", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "cannot be loaded"): + _request( + parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), + 2, + ) + """Validate the foundation before real core collectors are added.""" def test_artifacts_cannot_escape_collector_workspace(self) -> None: @@ -1089,6 +1127,62 @@ def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> Non self.assertIn("Status: partial", summary) self.assertIn("secondary evidence source was unavailable", summary) + def test_selected_collector_rerun_preserves_original_run_and_separates_evidence(self) -> None: + """Explicit reruns execute only selected original IDs and own a distinct evidence package.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_original", "z_selected"): + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + configuration = default_config(root) + original = RunSupervisor(root, configuration).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + ) + original_manifest = original.manifest_path.read_bytes() + original_package_path = Path(original.manifest.package["path"]) + original_package = original_package_path.read_bytes() + selected_id = "core.system.z_selected" + arguments = _parser().parse_args( + [ + "run", + "--rerun-from", + str(original.run_directory), + "--include", + selected_id, + "--acknowledge-authorization", + "--sequential", + ] + ) + request = _request(arguments, default_workers=4) + self.assertEqual(original.manifest.run_id, request.rerun_from) + rerun = RunSupervisor(root, configuration).run(build_plan(report, request)) + + self.assertEqual("succeeded", rerun.manifest.status.value) + self.assertEqual("rerun", rerun.manifest.action) + self.assertEqual(original.manifest.run_id, rerun.manifest.parent_run_id) + self.assertEqual((selected_id,), rerun.manifest.resolved_plan) + self.assertEqual([selected_id], [record.id for record in rerun.manifest.collectors]) + self.assertNotEqual(original.run_directory, rerun.run_directory) + self.assertNotEqual(original_package_path, Path(rerun.manifest.package["path"])) + self.assertEqual(original_manifest, original.manifest_path.read_bytes()) + self.assertEqual(original_package, original_package_path.read_bytes()) + with zipfile.ZipFile(Path(rerun.manifest.package["path"])) as archive: + packaged_manifest = json.loads(archive.read("manifest.json")) + summary = archive.read("summary.txt").decode("utf-8") + artifact_names = [name for name in archive.namelist() if name.startswith("artifacts/")] + self.assertEqual(original.manifest.run_id, packaged_manifest["parent_run_id"]) + self.assertEqual("rerun", packaged_manifest["action"]) + self.assertEqual(["artifacts/core_system_z_selected/system.txt"], artifact_names) + self.assertIn("Action: rerun", summary) + self.assertIn(f"Parent run: {original.manifest.run_id}", summary) + def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" with tempfile.TemporaryDirectory() as temporary: From 818ac0b255c47dfc87434dfcba7f00ff99ada255 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 16:29:53 +0300 Subject: [PATCH 152/316] feat: produce deterministic dependency-closed run plans --- TODO.md | 4 +- logicytics/manifest.py | 3 + logicytics/packaging.py | 1 + logicytics/planner.py | 43 +++++++++++- logicytics/runtime.py | 1 + tests/test_core_functionality.py | 116 ++++++++++++++++++++++++++++++- 6 files changed, 162 insertions(+), 6 deletions(-) diff --git a/TODO.md b/TODO.md index ec0f97d2..bca325e0 100644 --- a/TODO.md +++ b/TODO.md @@ -359,7 +359,7 @@ Windows platform allows. paths owned by the run context. - [ ] Replace `print`, `exit`, and process-level shutdowns inside collectors with typed results and typed errors returned to the orchestrator. -- [ ] Replace one giant execution-list algorithm with a planner that resolves +- [x] Replace one giant execution-list algorithm with a planner that resolves collector dependencies, removes duplicates, validates conflicts, and emits a reproducible execution plan before any collection begins. - [ ] Replace mode names that imply implementation details with user-facing @@ -395,7 +395,7 @@ Windows platform allows. successful or being silently discarded. - [x] Make reruns selectable by collector ID and keep rerun artifacts separate from the original run. -- [ ] Make the run plan deterministic: the same request and configuration produce +- [x] Make the run plan deterministic: the same request and configuration produce the same collector set and dependency order. ### Separate responsibilities into layers diff --git a/logicytics/manifest.py b/logicytics/manifest.py index bd267fbd..f5027a0f 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -118,6 +118,7 @@ class RunManifest: collectors: list[CollectorRecord] host: Mapping[str, str] resolved_plan: tuple[str, ...] = () + plan_fingerprint: str | None = None engine_version: str = CONTRACT_VERSION action: str = "run" parent_run_id: str | None = None @@ -137,6 +138,7 @@ def create( collector_sources: list[tuple[str, Path]], *, parent_run_id: str | None = None, + plan_fingerprint: str | None = None, ) -> "RunManifest": """Create the initial planned manifest before collection begins.""" return cls( @@ -148,6 +150,7 @@ def create( collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], resolved_plan=tuple(collector_id for collector_id, _ in collector_sources), + plan_fingerprint=plan_fingerprint, action="rerun" if parent_run_id is not None else "run", parent_run_id=parent_run_id, host={ diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 03690233..c81a7991 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -28,6 +28,7 @@ def _summary(manifest: RunManifest) -> str: f"Requested: {manifest.requested_at}", f"Finished: {manifest.finished_at or 'not finalized'}", f"Resolved collectors: {len(manifest.resolved_plan)}", + f"Plan fingerprint: {manifest.plan_fingerprint or 'unavailable'}", f"Skipped collectors: {len(manifest.skipped_collectors)}", f"Errors: {len(manifest.errors)}", "", diff --git a/logicytics/planner.py b/logicytics/planner.py index 2bf27b3e..1c3aca39 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -3,6 +3,9 @@ from __future__ import annotations import sys +import hashlib +import json +from dataclasses import asdict from dataclasses import dataclass from logicytics.contracts import Capability, CollectorKind, RunRequest @@ -17,6 +20,17 @@ class RunPlan: request: RunRequest collectors: tuple[CollectorCandidate, ...] + fingerprint: str + + +def _fingerprint(request: RunRequest, collectors: tuple[CollectorCandidate, ...]) -> str: + """Hash the immutable request and resolved metadata order into reproducibility evidence.""" + payload = { + "request": asdict(request), + "collectors": [candidate.metadata.to_dict() for candidate in collectors if candidate.metadata is not None], + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> bool: @@ -64,11 +78,12 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if request.max_workers < 1: raise PlanError("max_workers must be positive") valid = {candidate.metadata.id: candidate for candidate in report.valid if candidate.metadata is not None} - requested_names = {collector_id.rsplit(".", 1)[-1] for collector_id in request.include} invalid_selected = [ candidate for candidate in report.invalid - if candidate.kind is CollectorKind.CORE or candidate.path.stem in requested_names + if candidate.kind is CollectorKind.CORE + or candidate.selection_id in request.include + or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) ] if invalid_selected: details = "; ".join( @@ -84,6 +99,27 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: for collector_id, candidate in valid.items() if _selected_by_request(candidate, request) } + pending_dependencies = list(selected) + while pending_dependencies: + collector_id = pending_dependencies.pop() + candidate = selected[collector_id] + assert candidate.metadata is not None + for dependency_id in candidate.metadata.dependencies: + if dependency_id in request.exclude: + raise PlanError(f"selected collector {collector_id} depends on explicitly excluded {dependency_id}") + dependency = valid.get(dependency_id) + if dependency is None: + raise PlanError(f"selected collector {collector_id} depends on unavailable collector {dependency_id}") + assert dependency.metadata is not None + if dependency.kind is CollectorKind.PLUGIN and not ( + request.enable_plugins or dependency_id in request.include + ): + raise PlanError(f"plugin dependency {dependency_id} must be explicitly selected or plugins enabled") + if dependency.metadata.sensitive_data_categories and dependency_id not in request.include: + raise PlanError(f"sensitive dependency {dependency_id} must be explicitly selected") + if dependency_id not in selected: + selected[dependency_id] = dependency + pending_dependencies.append(dependency_id) for candidate in selected.values(): assert candidate.metadata is not None if sys.platform not in candidate.metadata.supported_platforms: @@ -102,4 +138,5 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: "selected collectors require an administrator account: " + ", ".join(elevated_collectors) ) - return RunPlan(request=request, collectors=_topological_order(selected)) + ordered = _topological_order(selected) + return RunPlan(request=request, collectors=ordered, fingerprint=_fingerprint(request, ordered)) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index e3e4dc65..ac1e8d7d 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -371,6 +371,7 @@ def run(self, plan: RunPlan) -> RunOutcome: self.configuration.to_manifest_dict(), [(candidate.metadata.id, candidate.path) for candidate in plan.collectors if candidate.metadata], parent_run_id=plan.request.rerun_from, + plan_fingerprint=plan.fingerprint, ) write_manifest(manifest_path, manifest) manifest.status = RunStatus.RUNNING diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 828c82ea..88c60641 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -34,7 +34,7 @@ RunRequest, Specialty, ) -from logicytics.discovery import preflight +from logicytics.discovery import PreflightReport, preflight from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError from logicytics.packaging import package_run @@ -1025,6 +1025,8 @@ def capture_manifest_state(path, manifest): self.assertEqual("succeeded", persisted_states[-1]) self.assertEqual("succeeded", outcome.manifest.status.value) self.assertEqual(("core.system.system_info",), outcome.manifest.resolved_plan) + self.assertEqual(plan.fingerprint, outcome.manifest.plan_fingerprint) + self.assertEqual(64, len(plan.fingerprint)) self.assertFalse(outcome.manifest.cancellation_requested) self.assertEqual([], outcome.manifest.errors) self.assertEqual([], outcome.manifest.skipped_collectors) @@ -1065,6 +1067,7 @@ def capture_manifest_state(path, manifest): self.assertEqual(("copied into run artifact store",), artifact.transformations) packaged_manifest = json.loads(archive.read("manifest.json")) self.assertEqual(["core.system.system_info"], packaged_manifest["resolved_plan"]) + self.assertEqual(plan.fingerprint, packaged_manifest["plan_fingerprint"]) self.assertFalse(packaged_manifest["cancellation_requested"]) self.assertEqual([], packaged_manifest["errors"]) self.assertEqual([], packaged_manifest["skipped_collectors"]) @@ -1077,6 +1080,7 @@ def capture_manifest_state(path, manifest): self.assertIn(f"Status: {record.status}", summary) self.assertIn("Cancellation requested: false", summary) self.assertIn("Resolved collectors: 1", summary) + self.assertIn(f"Plan fingerprint: {plan.fingerprint}", summary) self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) @@ -1785,6 +1789,97 @@ def test_dependencies_finish_before_dependents_start(self) -> None: self.assertIsNotNone(dependent.started_at) self.assertLessEqual(dependency.finished_at, dependent.started_at) + def test_dependency_closure_and_plan_fingerprint_are_deterministic(self) -> None: + """Diamond dependencies resolve once and hash identically regardless of discovery order.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependencies = { + "a_root": (), + "b_left": ("core.system.a_root",), + "c_right": ("core.system.a_root",), + "z_target": ("core.system.c_right", "core.system.b_left"), + } + for filename, required in dependencies.items(): + (core_directory / f"{filename}.py").write_text( + _delayed_collector_source(filename, 0.0, dependencies=required), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + request = RunRequest( + include=("core.system.z_target",), + rerun_from="run-" + "a" * 32, + acknowledge_authorization=True, + ) + first = build_plan(report, request) + reversed_plan = build_plan(PreflightReport(tuple(reversed(report.candidates))), request) + expected = ( + "core.system.a_root", + "core.system.b_left", + "core.system.c_right", + "core.system.z_target", + ) + + self.assertEqual(expected, tuple(candidate.metadata.id for candidate in first.collectors)) + self.assertEqual(expected, tuple(candidate.metadata.id for candidate in reversed_plan.collectors)) + self.assertEqual(first.fingerprint, reversed_plan.fingerprint) + self.assertEqual(64, len(first.fingerprint)) + + def test_planner_rejects_excluded_missing_and_sensitive_dependency_conflicts(self) -> None: + """Dependency expansion cannot override explicit exclusions or sensitive opt-in boundaries.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + target_id = "core.system.z_target" + (core_directory / "a_dependency.py").write_text( + _delayed_collector_source("a_dependency", 0.0), + encoding="utf-8", + ) + (core_directory / "z_target.py").write_text( + _delayed_collector_source("z_target", 0.0, dependencies=(dependency_id,)), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "explicitly excluded"): + build_plan(preflight(root), RunRequest(include=(target_id,), exclude=(dependency_id,))) + sensitive_source = _delayed_collector_source("a_dependency", 0.0).replace( + "from logicytics import CollectorMetadata,", + "from logicytics import Capability, CollectorMetadata,", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SENSITIVE_FILES,),\n' + ' sensitive_data_categories=("credentials",),\n' + ' default_profiles=("deep",),', + ) + (core_directory / "a_dependency.py").write_text(sensitive_source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + with self.assertRaisesRegex(PlanError, "sensitive dependency"): + build_plan(report, RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,))) + approved = build_plan( + report, + RunRequest( + include=(target_id, dependency_id), + approved_capabilities=(Capability.SENSITIVE_FILES,), + ), + ) + self.assertEqual([dependency_id, target_id], [candidate.metadata.id for candidate in approved.collectors]) + (core_directory / "a_dependency.py").unlink() + with self.assertRaisesRegex(PlanError, "unavailable collector"): + build_plan(preflight(root), RunRequest(include=(target_id,))) + (core_directory / "a_dependency.py").write_text( + _delayed_collector_source("a_dependency", 0.0, dependencies=(target_id,)), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "dependency cycle"): + build_plan(preflight(root), RunRequest(include=(target_id,))) + def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: """A failed prerequisite must contain failure and prevent dependent execution.""" with tempfile.TemporaryDirectory() as temporary: @@ -2508,6 +2603,25 @@ def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: [candidate.metadata.id for candidate in plan.collectors], ) + def test_invalid_selected_folder_or_enabled_plugin_blocks_planning(self) -> None: + """Folder-owned invalid plugins fail closed by logical ID and explicit enablement.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text(_COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "broken_folder" / "main.py" + plugin_path.parent.mkdir(parents=True) + plugin_path.write_text('"""Invalid folder-owned plugin collector."""\n', encoding="utf-8") + report = preflight(root) + + self.assertEqual("plugin.broken_folder", report.invalid[0].selection_id) + build_plan(report, RunRequest()) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest(include=("plugin.broken_folder",))) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest(enable_plugins=True)) + def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(self) -> None: """The public preflight report exposes actionable diagnostics and fail-closed selection.""" with tempfile.TemporaryDirectory() as temporary: From 24e8d7a2fc06e687ee55b1ffb920c68823cd0f8f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 21:44:43 +0300 Subject: [PATCH 153/316] feat: unify run evidence and packages under output data --- .gitignore | 1 + README.md | 13 +++++--- TODO.md | 16 ++++----- logicytics/configuration.py | 4 +-- logicytics/packaging.py | 2 +- tests/test_core_functionality.py | 56 ++++++++++++++++++++++++++++++++ 6 files changed, 77 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index f07ec08a..704e34a4 100644 --- a/.gitignore +++ b/.gitignore @@ -319,6 +319,7 @@ $RECYCLE.BIN/ *.pyc /CODE/SysInternal_Suite/.sys.ignore /ACCESS/ +/output/ /CODE/vulnscan/tools/NN features/ /CODE/logicytics/User_History.json.gz /CODE/logicytics/User_History.json diff --git a/README.md b/README.md index f3d58934..e0ce9e84 100644 --- a/README.md +++ b/README.md @@ -63,8 +63,12 @@ core/ system_info.py # shipped collectors plugins/ # user-created collectors, validated separately logicytics/ # engine, contracts, planning, runtime, and packaging -tests/ # core integration tests -ACCESS/ # ignored generated runs and packages +tests/ # core integration tests +output/ # ignored generated evidence and diagnostics + data/ + run-/ # isolated run artifacts, manifest, and logs/ + run-.zip # verified evidence package + run-.zip.sha256 # SHA-256 package sidecar ``` ## Requirements @@ -87,8 +91,9 @@ python -m logicytics run --profile standard --acknowledge-authorization `preflight` reports valid and quarantined collectors. `plan` resolves the exact collector order without collecting data. `run` requires an explicit authorization -acknowledgement and writes results below `ACCESS/RUNS/`, with ZIP packages and -SHA-256 sidecars under `ACCESS/PACKAGES/`. +acknowledgement and writes each run below `output/data/run-/`. Its verified +ZIP package and SHA-256 sidecar are written alongside that run directory under +the same `output/data/` root; collector workspaces and `logs/` remain run-scoped. Collectors that request additional access must be explicitly approved, for example: diff --git a/TODO.md b/TODO.md index bca325e0..16ac5556 100644 --- a/TODO.md +++ b/TODO.md @@ -313,7 +313,7 @@ Windows platform allows. - [x] Launch each collector in its own worker process rather than importing and running all collectors in the main process. - [x] Give each collector a private working directory: - `ACCESS/RUNS//collectors//`. + `output/data//collectors//`. - [x] Give each collector a private temporary directory, stdout/stderr capture, structured event channel, and artifact staging area. - [x] Pass only a serializable `CollectorContext`/request payload into the worker; @@ -378,7 +378,7 @@ Windows platform allows. ### Define the v4.0 run model - [x] Create a unique run directory before collection starts: - `ACCESS/RUNS//`. + `output/data//`. - [x] Store raw collector output under a controlled artifact tree rather than in the source tree or project root. - [x] Create a run manifest at the beginning, update it as collectors start and @@ -616,7 +616,7 @@ Windows platform allows. - [ ] Track per-flag usage counts. - [ ] Provide `--usage` statistics including total interactions, average accuracy, common device/input values, and per-flag frequency. -- [ ] Generate a flag-usage bar graph in the ACCESS/DATA area. +- [ ] Generate a flag-usage bar graph in the output/data area. - [ ] Allow model debug/progress output to be enabled independently from normal Logicytics debug logging. @@ -659,7 +659,7 @@ Windows platform allows. - [ ] Provide a singleton logger shared by the engine and collectors. - [ ] Log to console with configurable colors and levels. -- [ ] Log to ACCESS/LOGS/Logicytics.log. +- [ ] Log to output/logs/Logicytics.log. - [ ] Support DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, and EXCEPTION levels. - [ ] Include timestamps and structured, readable log rows. - [ ] Support log truncation controls and optional deletion of the previous log. @@ -673,8 +673,8 @@ Windows platform allows. ### Output and packaging -- [ ] Create ACCESS/LOGS, ACCESS/LOGS/DEBUG, ACCESS/LOGS/PERFORMANCE, - ACCESS/DATA/Zip, and ACCESS/DATA/Hashes automatically. +- [ ] Create output/logs, output/logs/debug, output/logs/performance, + output/data/zip, and output/data/hashes automatically. - [ ] Collect generated files without including source code, executables, model files, configuration secrets, caches, or library internals in the evidence package. @@ -683,7 +683,7 @@ Windows platform allows. - [ ] Preserve nested directory structure inside packages. - [ ] Name packages using the action and timestamp. - [ ] Generate a SHA-256 digest for each package and move both artifacts into the - ACCESS/DATA output directories. + output/data output directories. - [ ] Provide a file-opening helper for viewing generated artifacts. - [ ] Implement the v4.0 temporary-workspace lifecycle: collectors write into a run-specific temporary directory, the packager consumes that directory, and @@ -881,7 +881,7 @@ appropriate default/deep/non-Python mode. - [ ] Implement configuration loading, structured logging, file discovery, command execution, privilege checks, cancellation, and error aggregation. - [ ] Implement the temporary run directory and cleanup lifecycle. -- [ ] Rename ACCESS folder to output/ and LOGS/ to logs/, PACKAGES/ to data/ (and remove RUNS/ as it should be the same as data/) +- [x] Rename ACCESS folder to output/ and LOGS/ to logs/, PACKAGES/ to data/ (and remove RUNS/ as it should be the same as data/) ### Phase 2 — Engine and packaging diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 2ff5f087..e3f6dede 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -78,7 +78,7 @@ def to_manifest_dict(self) -> dict[str, Any]: def default_config(project_root: Path) -> AppConfig: """Create safe defaults rooted at the checked-out project.""" return AppConfig(schema_version=SCHEMA_VERSION, - runtime=RuntimeSettings(output_root=project_root / "ACCESS" / "RUNS")) + runtime=RuntimeSettings(output_root=project_root / "output" / "data")) def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: @@ -101,7 +101,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi runtime_raw = raw.get("runtime", {}) if not isinstance(runtime_raw, dict): raise PlanError("runtime configuration must be an object") - output_root_value = runtime_raw.get("output_root", project_root / "ACCESS" / "RUNS") + output_root_value = runtime_raw.get("output_root", project_root / "output" / "data") if not isinstance(output_root_value, (str, Path)) or not str(output_root_value).strip(): raise PlanError("runtime output_root must be a non-empty path string") output_root = Path(output_root_value) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index c81a7991..7b600bd0 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -172,7 +172,7 @@ def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: s def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: Path) -> tuple[Path, Path]: """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" - package_directory = run_directory.parent.parent / "PACKAGES" + package_directory = run_directory.parent package_directory.mkdir(parents=True, exist_ok=True) package_path = package_directory / f"{manifest.run_id}.zip" hash_path = package_path.with_suffix(".zip.sha256") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 88c60641..aba9c4c1 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -449,6 +449,23 @@ def test_configuration_schema_version_is_enforced(self) -> None: with self.assertRaisesRegex(PlanError, "package_completed_runs"): load_config(root) + def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: + """Defaults and loaded settings share output/data while explicit roots remain supported.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + expected = root / "output" / "data" + self.assertEqual(expected, default_config(root).runtime.output_root) + self.assertEqual(expected, load_config(root).runtime.output_root) + config_path = root / "logicytics.json" + config_path.write_text('{"schema_version":4,"runtime":{}}', encoding="utf-8") + self.assertEqual(expected, load_config(root).runtime.output_root) + config_path.write_text( + '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', + encoding="utf-8", + ) + self.assertEqual(root / "custom" / "evidence", load_config(root).runtime.output_root) + self.assertFalse(expected.exists()) + def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings(self) -> None: """Manifest snapshots hide credentials while collectors retain configured access.""" with tempfile.TemporaryDirectory() as temporary: @@ -1031,6 +1048,7 @@ def capture_manifest_state(path, manifest): self.assertEqual([], outcome.manifest.errors) self.assertEqual([], outcome.manifest.skipped_collectors) self.assertEqual(configuration.runtime.output_root, outcome.run_directory.parent) + self.assertEqual(root / "output" / "data", outcome.run_directory.parent) self.assertTrue(outcome.run_directory.is_absolute()) self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info").is_dir()) self.assertFalse((root / "system.txt").exists()) @@ -1045,6 +1063,12 @@ def capture_manifest_state(path, manifest): self.assertIsNotNone(outcome.manifest.package) self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) self.assertTrue(Path(outcome.manifest.package["sha256_path"]).is_file()) + self.assertEqual(outcome.run_directory.parent, Path(outcome.manifest.package["path"]).parent) + self.assertEqual(outcome.run_directory.parent, Path(outcome.manifest.package["sha256_path"]).parent) + self.assertTrue((outcome.run_directory / "logs" / "engine.jsonl").is_file()) + self.assertFalse((root / "ACCESS").exists()) + self.assertFalse((root / "output" / "RUNS").exists()) + self.assertFalse((root / "output" / "PACKAGES").exists()) package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) @@ -1088,6 +1112,38 @@ def capture_manifest_state(path, manifest): self.assertNotEqual(outcome.manifest.run_id, repeated.manifest.run_id) self.assertNotEqual(outcome.run_directory, repeated.run_directory) + def test_configured_output_root_colocates_packages_without_touching_legacy_evidence(self) -> None: + """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + legacy_evidence = root / "ACCESS" / "RUNS" / "legacy-evidence.txt" + legacy_evidence.parent.mkdir(parents=True) + legacy_evidence.write_text("preserve existing evidence", encoding="utf-8") + (root / "logicytics.json").write_text( + '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', + encoding="utf-8", + ) + configuration = load_config(root) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + expected_root = root / "custom" / "evidence" + package_path = Path(outcome.manifest.package["path"]) + hash_path = Path(outcome.manifest.package["sha256_path"]) + + self.assertEqual(expected_root, outcome.run_directory.parent) + self.assertEqual(expected_root, package_path.parent) + self.assertEqual(expected_root, hash_path.parent) + self.assertEqual("preserve existing evidence", legacy_evidence.read_text(encoding="utf-8")) + self.assertFalse((root / "custom" / "PACKAGES").exists()) + self.assertEqual( + hashlib.sha256(package_path.read_bytes()).hexdigest(), + hash_path.read_text(encoding="ascii").split()[0], + ) + def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> None: """Partial terminal outcomes preserve evidence without masquerading as success.""" with tempfile.TemporaryDirectory() as temporary: From 55ed22bb475d7051024ef83c67774109bb041f4c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 21:50:15 +0300 Subject: [PATCH 154/316] feat: validate and package manifest-wide evidence catalogs --- TODO.md | 8 ++-- logicytics/artifacts.py | 7 +++ logicytics/contracts.py | 37 +++++++++++++++ logicytics/manifest.py | 41 +++++++++++++--- logicytics/packaging.py | 63 ++++++++++++------------- logicytics/runtime.py | 20 ++++---- tests/test_core_functionality.py | 80 ++++++++++++++++++++++++++++++++ 7 files changed, 204 insertions(+), 52 deletions(-) diff --git a/TODO.md b/TODO.md index 16ac5556..2d573e17 100644 --- a/TODO.md +++ b/TODO.md @@ -367,10 +367,10 @@ Windows platform allows. make profiles the canonical internal concept. - [ ] Replace a single shared mutable logger with run-scoped structured logging that can still be rendered to the existing console/log-file formats. -- [ ] Replace ad hoc output filenames with an artifact catalog containing stable +- [x] Replace ad hoc output filenames with an artifact catalog containing stable artifact IDs, human-readable names, MIME/type information, byte counts, timestamps, producer collector, and status. -- [ ] Replace “zip whatever happens to be in CODE” with packaging the run's +- [x] Replace “zip whatever happens to be in CODE” with packaging the run's artifact catalog and manifest only. - [ ] Replace cleanup based on deleting a discovered file list with ownership-based cleanup that can remove only the current run's temporary workspace. @@ -408,9 +408,9 @@ Windows platform allows. services; do not import the main application or depend on global state. - [ ] **Platform layer:** centralize PowerShell, WMIC/WMI, registry, filesystem, process, network, and privilege access behind testable adapters. -- [ ] **Artifact layer:** validate paths, stream output, normalize metadata, compute +- [x] **Artifact layer:** validate paths, stream output, normalize metadata, compute checksums, enforce size limits, and register artifacts. -- [ ] **Packaging layer:** consume the finalized artifact catalog and create ZIP, +- [x] **Packaging layer:** consume the finalized artifact catalog and create ZIP, hash, manifest, and summary outputs. - [ ] **Maintenance layer:** keep update, debug, developer checks, and usage analytics from being coupled to normal collection. diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index f880920a..cfef16a4 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -4,6 +4,7 @@ import hashlib import os +import re import shutil from datetime import datetime, timezone from pathlib import Path @@ -103,6 +104,11 @@ def _register_file( media_type: str, transformations: tuple[str, ...], ) -> Artifact: + if not isinstance(media_type, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*", + media_type, + ): + raise ArtifactError("artifact media_type must be a valid MIME type") if not isinstance(transformations, tuple) or any( not isinstance(step, str) or not step.strip() for step in transformations ): @@ -138,6 +144,7 @@ def _register_file( source_category=self._source_category, collected_at=datetime.now(timezone.utc).isoformat(), transformations=(*transformations, "copied into run artifact store"), + name=destination.name, ) self._bytes_registered += size_bytes self._artifacts.append(artifact) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 090db06b..8416e236 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -3,6 +3,7 @@ from __future__ import annotations import re +from datetime import datetime from math import isfinite from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field @@ -17,6 +18,9 @@ _CONTRACT_VERSION = re.compile(r"^\d+\.\d+$") _LABEL = re.compile(r"^[a-z][a-z0-9_]{0,63}$") _RUN_ID = re.compile(r"^run-[0-9a-f]{32}$") +_ARTIFACT_ID = re.compile(r"^artifact\.[0-9a-f]{32}$") +_ARTIFACT_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_MEDIA_TYPE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*$") class CollectorKind(StrEnum): @@ -233,6 +237,39 @@ class Artifact: source_category: str collected_at: str transformations: tuple[str, ...] + name: str + status: str = "registered" + + def __post_init__(self) -> None: + """Reject malformed evidence records before worker or package publication.""" + if not isinstance(self.id, str) or not _ARTIFACT_ID.fullmatch(self.id): + raise ValueError("artifact id must be a stable artifact identifier") + if not isinstance(self.relative_path, str) or not self.relative_path.strip(): + raise ValueError("artifact relative_path must be a non-empty string") + if not isinstance(self.sha256, str) or not _ARTIFACT_SHA256.fullmatch(self.sha256): + raise ValueError("artifact sha256 must be a lowercase SHA-256 digest") + if not isinstance(self.size_bytes, int) or isinstance(self.size_bytes, bool) or self.size_bytes < 0: + raise ValueError("artifact size_bytes must be a non-negative integer") + if not isinstance(self.media_type, str) or not _MEDIA_TYPE.fullmatch(self.media_type): + raise ValueError("artifact media_type must be a valid MIME type") + if not isinstance(self.collector_id, str) or not _COLLECTOR_ID.fullmatch(self.collector_id): + raise ValueError("artifact collector_id must identify its producing collector") + if not isinstance(self.source_category, str) or not _LABEL.fullmatch(self.source_category): + raise ValueError("artifact source_category must be a lowercase category label") + if not isinstance(self.name, str) or not self.name.strip() or any(value in self.name for value in "\r\n/\\"): + raise ValueError("artifact name must be a safe human-readable filename") + if self.status != "registered": + raise ValueError("artifact status must be registered") + try: + collected_at = datetime.fromisoformat(self.collected_at) + except (TypeError, ValueError) as error: + raise ValueError("artifact collected_at must be an ISO-8601 timestamp") from error + if collected_at.tzinfo is None: + raise ValueError("artifact collected_at must include a timezone") + if not isinstance(self.transformations, tuple) or any( + not isinstance(step, str) or not step.strip() for step in self.transformations + ): + raise ValueError("artifact transformations must be a tuple of non-empty strings") def to_dict(self) -> dict[str, Any]: return asdict(self) diff --git a/logicytics/manifest.py b/logicytics/manifest.py index f5027a0f..7ecdef6f 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -9,7 +9,7 @@ import getpass from dataclasses import asdict, dataclass, field from datetime import datetime, timezone -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any, Mapping from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus @@ -185,16 +185,45 @@ def finalize_status(self) -> None: def artifact_list(self) -> tuple[Artifact, ...]: """Return all registered artifacts reconstructed from the manifest.""" - return tuple( - Artifact(**artifact) - for record in self.collectors - for artifact in record.artifacts - ) + return tuple(Artifact(**{key: value for key, value in item.items() if key != "producer_status"}) + for item in self.artifact_catalog()) + + def artifact_catalog(self) -> tuple[dict[str, Any], ...]: + """Build the globally unique, ownership-validated run-wide evidence catalog.""" + catalog: list[dict[str, Any]] = [] + artifact_ids: set[str] = set() + artifact_paths: set[str] = set() + for record in self.collectors: + for item in record.artifacts: + artifact = Artifact(**item) + if artifact.collector_id != record.id: + raise ValueError(f"manifest artifact collector ownership is invalid: {artifact.relative_path}") + relative = PurePosixPath(artifact.relative_path) + if ( + "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != record.id.replace(".", "_") + ): + raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") + if artifact.name != relative.name: + raise ValueError(f"manifest artifact name does not match its registered path: {artifact.name}") + if artifact.id in artifact_ids: + raise ValueError(f"manifest contains duplicate artifact id: {artifact.id}") + if artifact.relative_path in artifact_paths: + raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") + artifact_ids.add(artifact.id) + artifact_paths.add(artifact.relative_path) + catalog.append({**artifact.to_dict(), "producer_status": record.status}) + return tuple(catalog) def to_dict(self) -> dict[str, Any]: """Produce JSON-safe manifest data.""" data = asdict(self) data["status"] = self.status.value + data["artifact_catalog"] = self.artifact_catalog() return data diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 7b600bd0..9fde8ebe 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -77,39 +77,36 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ sources: list[tuple[Path, str]] = [] archive_names: set[str] = set() artifact_root = (run_directory / "artifacts").resolve() - for record in manifest.collectors: - for item in record.artifacts: - artifact = Artifact(**item) - if artifact.collector_id != record.id: - raise ValueError(f"manifest artifact collector ownership is invalid: {artifact.relative_path}") - relative = PurePosixPath(artifact.relative_path) - owner = record.id.replace(".", "_") - if ( - not artifact.relative_path - or "\\" in artifact.relative_path - or relative.is_absolute() - or ".." in relative.parts - or relative.as_posix() != artifact.relative_path - or len(relative.parts) < 2 - or relative.parts[0] != owner - ): - raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") - source = artifact_root.joinpath(*relative.parts) - if not source.is_file(): - raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") - try: - source.resolve(strict=True).relative_to(artifact_root / owner) - except (OSError, ValueError) as error: - raise ValueError( - f"manifest artifact escapes its collector-owned store: {artifact.relative_path}" - ) from error - archive_name = f"artifacts/{relative.as_posix()}" - if archive_name in archive_names: - raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") - if source.stat().st_size != artifact.size_bytes or sha256_file(source) != artifact.sha256: - raise ValueError(f"manifest artifact verification failed: {artifact.relative_path}") - archive_names.add(archive_name) - sources.append((source, archive_name)) + for item in manifest.artifact_catalog(): + artifact = Artifact(**{key: value for key, value in item.items() if key != "producer_status"}) + relative = PurePosixPath(artifact.relative_path) + owner = artifact.collector_id.replace(".", "_") + if ( + not artifact.relative_path + or "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != owner + ): + raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") + source = artifact_root.joinpath(*relative.parts) + if not source.is_file(): + raise FileNotFoundError(f"manifest artifact is missing: {artifact.relative_path}") + try: + source.resolve(strict=True).relative_to(artifact_root / owner) + except (OSError, ValueError) as error: + raise ValueError( + f"manifest artifact escapes its collector-owned store: {artifact.relative_path}" + ) from error + archive_name = f"artifacts/{relative.as_posix()}" + if archive_name in archive_names: + raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") + if source.stat().st_size != artifact.size_bytes or sha256_file(source) != artifact.sha256: + raise ValueError(f"manifest artifact verification failed: {artifact.relative_path}") + archive_names.add(archive_name) + sources.append((source, archive_name)) return sources diff --git a/logicytics/runtime.py b/logicytics/runtime.py index ac1e8d7d..1f941950 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -171,15 +171,17 @@ def _load_collector(path: Path, expected_class: str): def _artifact_from_dict(data: dict[str, object]) -> Artifact: return Artifact( - id=str(data["id"]), - relative_path=str(data["relative_path"]), - sha256=str(data["sha256"]), - size_bytes=int(data["size_bytes"]), - media_type=str(data["media_type"]), - collector_id=str(data["collector_id"]), - source_category=str(data["source_category"]), - collected_at=str(data["collected_at"]), - transformations=tuple(str(step) for step in data["transformations"]), + id=data["id"], + relative_path=data["relative_path"], + sha256=data["sha256"], + size_bytes=data["size_bytes"], + media_type=data["media_type"], + collector_id=data["collector_id"], + source_category=data["source_category"], + collected_at=data["collected_at"], + transformations=data["transformations"], + name=data["name"], + status=data["status"], ) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index aba9c4c1..038442cf 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -26,6 +26,7 @@ from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config from logicytics.contracts import ( + Artifact, Capability, CollectorMetadata, CollectorResult, @@ -220,6 +221,40 @@ def test_collector_results_expose_all_strict_typed_terminal_states(self) -> None with self.assertRaisesRegex(ValueError, message): CollectorResult(**options) + def test_artifact_contract_rejects_malformed_catalog_metadata(self) -> None: + """Evidence records validate identity, MIME type, provenance, timestamps, and status.""" + valid = { + "id": "artifact." + "a" * 32, + "relative_path": "core_system_example/report.json", + "sha256": "b" * 64, + "size_bytes": 2, + "media_type": "application/json", + "collector_id": "core.system.example", + "source_category": "system", + "collected_at": "2026-01-01T00:00:00+00:00", + "transformations": ("normalized",), + "name": "report.json", + "status": "registered", + } + invalid = ( + ({"id": "artifact.invalid"}, "id"), + ({"sha256": "bad"}, "sha256"), + ({"size_bytes": True}, "size_bytes"), + ({"size_bytes": -1}, "size_bytes"), + ({"media_type": "not a mime type"}, "media_type"), + ({"collector_id": "another"}, "collector_id"), + ({"source_category": "System"}, "source_category"), + ({"collected_at": "2026-01-01T00:00:00"}, "timezone"), + ({"transformations": ["normalized"]}, "transformations"), + ({"name": "../report.json"}, "name"), + ({"status": "failed"}, "status"), + ) + self.assertEqual("report.json", Artifact(**valid).name) + for changes, message in invalid: + with self.subTest(changes=changes): + with self.assertRaisesRegex(ValueError, message): + Artifact(**{**valid, **changes}) + def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) -> None: """Default collection excludes sensitive evidence unless explicitly selected.""" with tempfile.TemporaryDirectory() as temporary: @@ -1007,7 +1042,12 @@ def test_artifact_registration_records_validated_provenance(self) -> None: ) with self.assertRaisesRegex(ArtifactError, "transformations"): writer.register_file(source, transformations=["normalized"]) # type: ignore[arg-type] + with self.assertRaisesRegex(ArtifactError, "media_type"): + writer.register_file(source, media_type="not a mime type") + self.assertEqual((), writer.artifacts) artifact = writer.register_file(source, transformations=("normalized",)) + self.assertEqual("report.json", artifact.name) + self.assertEqual("registered", artifact.status) self.assertEqual("evidence_graph", artifact.source_category) datetime.fromisoformat(artifact.collected_at) self.assertEqual( @@ -1096,9 +1136,16 @@ def capture_manifest_state(path, manifest): self.assertEqual([], packaged_manifest["errors"]) self.assertEqual([], packaged_manifest["skipped_collectors"]) packaged_artifact = packaged_manifest["collectors"][0]["artifacts"][0] + catalog_artifact = packaged_manifest["artifact_catalog"][0] self.assertEqual(artifact.source_category, packaged_artifact["source_category"]) self.assertEqual(artifact.collected_at, packaged_artifact["collected_at"]) self.assertEqual(list(artifact.transformations), packaged_artifact["transformations"]) + self.assertEqual(artifact.id, catalog_artifact["id"]) + self.assertEqual("system.txt", catalog_artifact["name"]) + self.assertEqual("text/plain", catalog_artifact["media_type"]) + self.assertEqual("registered", catalog_artifact["status"]) + self.assertEqual("succeeded", catalog_artifact["producer_status"]) + self.assertEqual("core.system.system_info", catalog_artifact["collector_id"]) record = outcome.manifest.collectors[0] summary = archive.read("summary.txt").decode("utf-8") self.assertIn(f"Status: {record.status}", summary) @@ -1543,6 +1590,39 @@ def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_owne with self.assertRaisesRegex(ValueError, "collector ownership"): package_run(outcome) + def test_package_rejects_forged_names_status_and_duplicate_catalog_identifiers(self) -> None: + """A package is refused whenever its finalized evidence catalog is inconsistent.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' second = context.workspace / "second.txt"\n' + ' second.write_text("second", encoding="utf-8")\n' + ' extra = context.artifacts.register_file(second, media_type="text/plain")\n' + ' return CollectorResult.succeeded("test artifacts created", (artifact, extra))', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + first, second = outcome.manifest.collectors[0].artifacts + original_name = first["name"] + first["name"] = "forged.txt" + with self.assertRaisesRegex(ValueError, "name does not match"): + package_run(outcome) + first["name"] = original_name + first["status"] = "deleted" + with self.assertRaisesRegex(ValueError, "status must be registered"): + package_run(outcome) + first["status"] = "registered" + second["id"] = first["id"] + with self.assertRaisesRegex(ValueError, "duplicate artifact id"): + package_run(outcome) + def test_package_rejects_collector_event_log_symlinks_outside_the_run(self) -> None: """A collector event-channel symlink must not smuggle external files into a ZIP.""" with tempfile.TemporaryDirectory() as temporary: From db5f045b6b718b8ce3949474c536abbe9474a9b9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 21:54:49 +0300 Subject: [PATCH 155/316] feat: publish timestamped action-aware evidence packages --- README.md | 10 +++--- TODO.md | 10 +++--- logicytics/packaging.py | 20 ++++++++++- tests/test_core_functionality.py | 57 ++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e0ce9e84..a766d996 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ tests/ # core integration tests output/ # ignored generated evidence and diagnostics data/ run-/ # isolated run artifacts, manifest, and logs/ - run-.zip # verified evidence package - run-.zip.sha256 # SHA-256 package sidecar + run--run-.zip # action/timestamp-named evidence package + run--run-.zip.sha256 # matching SHA-256 package sidecar ``` ## Requirements @@ -92,8 +92,10 @@ python -m logicytics run --profile standard --acknowledge-authorization `preflight` reports valid and quarantined collectors. `plan` resolves the exact collector order without collecting data. `run` requires an explicit authorization acknowledgement and writes each run below `output/data/run-/`. Its verified -ZIP package and SHA-256 sidecar are written alongside that run directory under -the same `output/data/` root; collector workspaces and `logs/` remain run-scoped. +ZIP package and SHA-256 sidecar use the action, UTC request timestamp, and run +ID, and are written alongside that run directory under the same `output/data/` +root; collector workspaces and `logs/` remain run-scoped. Explicit reruns use a +`rerun--run-.zip` package identity. Collectors that request additional access must be explicitly approved, for example: diff --git a/TODO.md b/TODO.md index 2d573e17..1364f51a 100644 --- a/TODO.md +++ b/TODO.md @@ -680,9 +680,9 @@ Windows platform allows. package. - [ ] Include generated files from CODE in the package. - [ ] Include MODS output in a separate MODS package during modded runs. -- [ ] Preserve nested directory structure inside packages. -- [ ] Name packages using the action and timestamp. -- [ ] Generate a SHA-256 digest for each package and move both artifacts into the +- [x] Preserve nested directory structure inside packages. +- [x] Name packages using the action and timestamp. +- [x] Generate a SHA-256 digest for each package and move both artifacts into the output/data output directories. - [ ] Provide a file-opening helper for viewing generated artifacts. - [ ] Implement the v4.0 temporary-workspace lifecycle: collectors write into a @@ -887,7 +887,7 @@ appropriate default/deep/non-Python mode. - [ ] Implement CLI parsing, mode selection, execution scheduling, worker limits, performance reporting, and post-run actions. -- [ ] Implement ZIP creation, SHA-256 hashing, package naming, metadata, and output +- [x] Implement ZIP creation, SHA-256 hashing, package naming, metadata, and output relocation. - [ ] Implement the MODS discovery and packaging contract. @@ -917,7 +917,7 @@ appropriate default/deep/non-Python mode. - [ ] Verify that every collector is represented in the CLI mode matrix and the v4.0 documentation. - [ ] Verify that packages contain generated evidence and metadata only. -- [ ] Verify that hashes reproduce from the final package bytes. +- [x] Verify that hashes reproduce from the final package bytes. - [ ] Update README, contribution guidance, configuration documentation, and the repository wiki for v4.0. diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 9fde8ebe..3dc6ab53 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -4,7 +4,9 @@ import hashlib import os +import re import zipfile +from datetime import datetime, timezone from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, BinaryIO @@ -18,6 +20,22 @@ _STREAM_BLOCK_BYTES = 1024 * 1024 +def _package_filename(manifest: RunManifest) -> str: + """Return a deterministic, filesystem-safe action and UTC run timestamp identity.""" + if not isinstance(manifest.action, str) or not re.fullmatch(r"run|rerun", manifest.action): + raise ValueError("package action must be a supported run or rerun action") + if not isinstance(manifest.run_id, str) or not re.fullmatch(r"run-[0-9a-f]{32}", manifest.run_id): + raise ValueError("package run_id must be a valid run identifier") + try: + requested_at = datetime.fromisoformat(manifest.requested_at) + except (TypeError, ValueError) as error: + raise ValueError("package requested_at must be a valid timestamp") from error + if requested_at.tzinfo is None: + raise ValueError("package requested_at must include a timezone") + timestamp = requested_at.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + return f"{manifest.action}-{timestamp}-{manifest.run_id}.zip" + + def _summary(manifest: RunManifest) -> str: lines = [ f"Logicytics run: {manifest.run_id}", @@ -171,7 +189,7 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" package_directory = run_directory.parent package_directory.mkdir(parents=True, exist_ok=True) - package_path = package_directory / f"{manifest.run_id}.zip" + package_path = package_directory / _package_filename(manifest) hash_path = package_path.with_suffix(".zip.sha256") summary_path = run_directory / "summary.txt" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 038442cf..f4f0d066 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1112,6 +1112,10 @@ def capture_manifest_state(path, manifest): package_path, hash_path = package_run(outcome) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) + requested_at = datetime.fromisoformat(outcome.manifest.requested_at) + timestamp = requested_at.strftime("%Y%m%dT%H%M%S.%fZ") + self.assertEqual(f"run-{timestamp}-{outcome.manifest.run_id}.zip", package_path.name) + self.assertEqual(f"{package_path.name}.sha256", hash_path.name) package_digest, sidecar_name = hash_path.read_text(encoding="ascii").split() self.assertEqual(package_path.name, sidecar_name) self.assertEqual(hashlib.sha256(package_path.read_bytes()).hexdigest(), package_digest) @@ -1278,6 +1282,9 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( self.assertEqual([selected_id], [record.id for record in rerun.manifest.collectors]) self.assertNotEqual(original.run_directory, rerun.run_directory) self.assertNotEqual(original_package_path, Path(rerun.manifest.package["path"])) + self.assertTrue(original_package_path.name.startswith("run-")) + self.assertTrue(Path(rerun.manifest.package["path"]).name.startswith("rerun-")) + self.assertIn(rerun.manifest.run_id, Path(rerun.manifest.package["path"]).name) self.assertEqual(original_manifest, original.manifest_path.read_bytes()) self.assertEqual(original_package, original_package_path.read_bytes()) with zipfile.ZipFile(Path(rerun.manifest.package["path"])) as archive: @@ -1590,6 +1597,56 @@ def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_owne with self.assertRaisesRegex(ValueError, "collector ownership"): package_run(outcome) + def test_package_name_rejects_unsafe_action_run_id_and_naive_timestamps(self) -> None: + """Manipulated manifest identity cannot escape the output root or obscure run timing.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + invalid = ( + ("action", "../outside", "action"), + ("run_id", "../outside", "run_id"), + ("requested_at", "2026-01-01T00:00:00", "timezone"), + ("requested_at", "not a timestamp", "timestamp"), + ) + for field, value, message in invalid: + with self.subTest(field=field, value=value): + original = getattr(outcome.manifest, field) + setattr(outcome.manifest, field, value) + try: + with self.assertRaisesRegex(ValueError, message): + package_run(outcome) + finally: + setattr(outcome.manifest, field, original) + self.assertFalse((root / "outside").exists()) + + def test_package_preserves_nested_registered_artifact_directories(self) -> None: + """Deep collector-owned evidence paths survive catalog publication and ZIP creation.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' output = context.workspace / "nested" / "reports" / "system.txt"\n' + ' output.parent.mkdir(parents=True)', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + expected = "core_system_system_info/nested/reports/system.txt" + self.assertEqual(expected, outcome.manifest.artifact_list()[0].relative_path) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + self.assertIn(f"artifacts/{expected}", archive.namelist()) + self.assertEqual(expected, json.loads(archive.read("manifest.json"))["artifact_catalog"][0]["relative_path"]) + def test_package_rejects_forged_names_status_and_duplicate_catalog_identifiers(self) -> None: """A package is refused whenever its finalized evidence catalog is inconsistent.""" with tempfile.TemporaryDirectory() as temporary: From c057daac8c1d879bb7f0cf9effc48e0a35909a8f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 21:59:31 +0300 Subject: [PATCH 156/316] feat: enforce documented collection profiles and offline isolation --- README.md | 12 ++++++ TODO.md | 2 +- logicytics/cli.py | 9 ++++- logicytics/planner.py | 17 ++++++++ tests/test_core_functionality.py | 69 +++++++++++++++++++++++++++++++- 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a766d996..681a9b79 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,18 @@ python -m logicytics run --profile standard --acknowledge-authorization ` --allow-capability filesystem_read ``` +## Collection profiles + +- `minimal`: only collectors that explicitly declare essential local inventory. +- `standard`: shipped core collectors that explicitly declare standard membership. +- `deep`: extended collector-declared inventory; sensitive capabilities still + require explicit approval. +- `offline`: collector-declared local inventory; network and packet-capture + capabilities remain forbidden even when explicitly included and approved. + +Plugin collectors never join any profile unless explicitly included or enabled +with `--plugins`. Unknown profile names are rejected before collection begins. + ## Collector rules Core collectors must live at `core//.py`. Each module diff --git a/TODO.md b/TODO.md index 1364f51a..5c2d78c9 100644 --- a/TODO.md +++ b/TODO.md @@ -460,7 +460,7 @@ Windows platform allows. collector's execution. - [ ] Separate product settings, profile settings, collector settings, and runtime overrides. -- [ ] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` +- [x] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` with documented collector membership. - [x] Allow explicit include/exclude collector selections to override a profile. - [ ] Version the configuration schema and migrate older configuration files. diff --git a/logicytics/cli.py b/logicytics/cli.py index f172b08c..53394281 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -16,7 +16,7 @@ from logicytics.discovery import preflight from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError -from logicytics.planner import build_plan +from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -81,7 +81,12 @@ def _parser() -> argparse.ArgumentParser: subcommands = parser.add_subparsers(dest="command", required=True) for command in ("preflight", "debug", "update", "plan", "run"): subparser = subcommands.add_parser(command) - subparser.add_argument("--profile", default="standard") + subparser.add_argument( + "--profile", + default="standard", + choices=tuple(BUILTIN_PROFILES), + help="Named built-in collector membership and access policy.", + ) subparser.add_argument("--include", action="append", default=[]) subparser.add_argument("--exclude", action="append", default=[]) subparser.add_argument("--plugins", action="store_true") diff --git a/logicytics/planner.py b/logicytics/planner.py index 1c3aca39..17368985 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -7,12 +7,21 @@ import json from dataclasses import asdict from dataclasses import dataclass +from types import MappingProxyType from logicytics.contracts import Capability, CollectorKind, RunRequest from logicytics.discovery import CollectorCandidate, PreflightReport from logicytics.environment import inspect_environment from logicytics.errors import PlanError, PreflightError +BUILTIN_PROFILES = MappingProxyType({ + "minimal": "Essential local system, memory, and storage inventory only.", + "standard": "Shipped core collectors explicitly declaring standard membership.", + "deep": "Extended declared inventory, subject to explicit capability approval.", + "offline": "Declared local-only inventory with network and packet access prohibited.", +}) +_OFFLINE_PROHIBITED_CAPABILITIES = frozenset({Capability.NETWORK, Capability.PACKET_CAPTURE}) + @dataclass(frozen=True, slots=True) class RunPlan: @@ -75,6 +84,9 @@ def visit(collector_id: str) -> None: def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: """Fail closed for invalid selected work and resolve a dependency-safe plan.""" + if request.profile not in BUILTIN_PROFILES: + supported = ", ".join(BUILTIN_PROFILES) + raise PlanError(f"unknown collection profile {request.profile!r}; supported profiles: {supported}") if request.max_workers < 1: raise PlanError("max_workers must be positive") valid = {candidate.metadata.id: candidate for candidate in report.valid if candidate.metadata is not None} @@ -122,6 +134,11 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: pending_dependencies.append(dependency_id) for candidate in selected.values(): assert candidate.metadata is not None + if request.profile == "offline": + prohibited = set(candidate.metadata.capabilities).intersection(_OFFLINE_PROHIBITED_CAPABILITIES) + if prohibited: + names = ", ".join(sorted(capability.value for capability in prohibited)) + raise PlanError(f"offline profile prohibits network-capable collector {candidate.metadata.id}: {names}") if sys.platform not in candidate.metadata.supported_platforms: raise PlanError(f"{candidate.metadata.id} does not support {sys.platform}") missing_capabilities = set(candidate.metadata.capabilities) - set(request.approved_capabilities) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index f4f0d066..fee8c9bd 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -39,7 +39,7 @@ from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError from logicytics.packaging import package_run -from logicytics.planner import build_plan +from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor @@ -300,6 +300,73 @@ def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) - ) self.assertEqual([sensitive_id], [item.metadata.id for item in deep.collectors]) + def test_builtin_profiles_select_declared_members_and_reject_unknown_names(self) -> None: + """Only documented profiles resolve collector-declared membership before any run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + memberships = { + "a_essential": ("minimal", "standard", "deep", "offline"), + "b_standard": ("standard", "deep"), + "z_deep": ("deep",), + } + for filename, profiles in memberships.items(): + source = _delayed_collector_source(filename, 0.0).replace( + ' supported_platforms=("win32",),', + f' supported_platforms=("win32",),\n default_profiles={profiles!r},', + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + expected = { + "minimal": ["core.system.a_essential"], + "standard": ["core.system.a_essential", "core.system.b_standard"], + "deep": ["core.system.a_essential", "core.system.b_standard", "core.system.z_deep"], + "offline": ["core.system.a_essential"], + } + self.assertEqual(set(expected), set(BUILTIN_PROFILES)) + for profile, collector_ids in expected.items(): + with self.subTest(profile=profile): + plan = build_plan(report, RunRequest(profile=profile)) + self.assertEqual(collector_ids, [candidate.metadata.id for candidate in plan.collectors]) + with self.assertRaisesRegex(PlanError, "unknown collection profile"): + build_plan(report, RunRequest(profile="invented")) + with patch("sys.stderr", new_callable=io.StringIO) as errors: + with self.assertRaises(SystemExit): + _parser().parse_args(["plan", "--profile", "invented"]) + self.assertIn("invalid choice", errors.getvalue()) + + def test_offline_profile_rejects_network_collectors_even_with_explicit_approval(self) -> None: + """Offline collection remains local-only regardless of include and capability overrides.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + collector_id = "core.system.network_source" + source = _delayed_collector_source("network_source", 0.0).replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.NETWORK,),\n' + ' default_profiles=("standard",),', + ) + (core_directory / "network_source.py").write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + standard = build_plan(report, RunRequest(approved_capabilities=(Capability.NETWORK,))) + self.assertEqual([collector_id], [candidate.metadata.id for candidate in standard.collectors]) + self.assertEqual((), build_plan(report, RunRequest(profile="offline")).collectors) + with self.assertRaisesRegex(PlanError, "offline profile prohibits network-capable"): + build_plan( + report, + RunRequest(profile="offline", include=(collector_id,), approved_capabilities=(Capability.NETWORK,)), + ) + def test_preflight_rejects_unregistered_collector_resource_classes(self) -> None: """Invalid scheduling metadata blocks shipped collectors before any run starts.""" with tempfile.TemporaryDirectory() as temporary: From 4deabdb529ea0855231cd90fdc8cb835159596cb Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 22:05:29 +0300 Subject: [PATCH 157/316] feat: validate typed collector configuration before planning --- README.md | 21 +++++ TODO.md | 4 +- logicytics/configuration.py | 137 ++++++++++++++++++++++++++----- tests/test_core_functionality.py | 100 ++++++++++++++++++++++ 4 files changed, 241 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 681a9b79..ae321a49 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,27 @@ python -m logicytics run --profile standard --acknowledge-authorization ` Plugin collectors never join any profile unless explicitly included or enabled with `--plugins`. Unknown profile names are rejected before collection begins. +## Configuration + +An optional project-root `logicytics.json` uses schema version `4` and separates +engine-wide `runtime` options from per-collector `collectors` settings: + +```json +{ + "schema_version": 4, + "runtime": {"default_max_workers": 4, "maximum_workers": 8}, + "collectors": { + "core.filesystem.system_drive_tree": {"max_entries": 5000, "max_depth": 12}, + "core.process.memory_map": {"max_regions": 5000, "dump_directory": "memory_maps"} + } +} +``` + +Shipped filesystem, network, packet, sensitive-inventory, and metadata-only +memory-map collector options are strictly typed and bounded before planning. +Unknown engine fields, duplicate JSON keys, unsafe workspace paths, and invalid +collector IDs fail closed; plugin-owned setting fields remain extensible. + ## Collector rules Core collectors must live at `core//.py`. Each module diff --git a/TODO.md b/TODO.md index 5c2d78c9..367ec0e2 100644 --- a/TODO.md +++ b/TODO.md @@ -455,8 +455,8 @@ Windows platform allows. ### Make configuration and profiles understandable -- [ ] Replace scattered string lookups with a validated typed configuration object. -- [ ] Fail at plan-validation time for invalid values, rather than during a +- [x] Replace scattered string lookups with a validated typed configuration object. +- [x] Fail at plan-validation time for invalid values, rather than during a collector's execution. - [ ] Separate product settings, profile settings, collector settings, and runtime overrides. diff --git a/logicytics/configuration.py b/logicytics/configuration.py index e3f6dede..59d8f2b7 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -3,8 +3,11 @@ from __future__ import annotations import json +import re from dataclasses import asdict, dataclass, field +from math import isfinite from pathlib import Path +from types import MappingProxyType from typing import Any, Mapping from logicytics.errors import PlanError @@ -13,6 +16,55 @@ SCHEMA_VERSION = 4 DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 +_COLLECTOR_ID = re.compile(r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|plugin\.[a-z][a-z0-9_]*)$") +_SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") +_ROOT_FIELDS = frozenset({"schema_version", "runtime", "collectors"}) +_RUNTIME_FIELDS = frozenset({ + "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", +}) + + +@dataclass(frozen=True, slots=True) +class CollectorSettingRule: + """One immutable collector configuration field and its safe value boundary.""" + + kind: str + minimum: int | float = 0 + maximum: int | float = 0 + + +_COLLECTOR_SETTING_SCHEMAS = MappingProxyType({ + "core.network.bandwidth_sample": { + "sample_count": CollectorSettingRule("integer", 1, 10), + "interval_seconds": CollectorSettingRule("integer", 1, 5), + }, + "core.packet.packet_capture": { + "packet_count": CollectorSettingRule("integer", 1, 10_000), + "timeout_seconds": CollectorSettingRule("number", 1, 60), + "retry_window_seconds": CollectorSettingRule("number", 0, 60), + "interface": CollectorSettingRule("text"), + }, + "core.filesystem.system_drive_tree": { + "max_entries": CollectorSettingRule("integer", 1, 50_000), + "max_depth": CollectorSettingRule("integer", 1, 32), + }, + "core.filesystem.system_drive_listing": { + "max_entries": CollectorSettingRule("integer", 1, 50_000), + "max_depth": CollectorSettingRule("integer", 1, 32), + "workers": CollectorSettingRule("integer", 1, 8), + }, + "core.filesystem.sensitive_file_inventory": { + "root": CollectorSettingRule("absolute_path"), + "max_directories": CollectorSettingRule("integer", 1, 50_000), + "max_matches": CollectorSettingRule("integer", 1, 5_000), + }, + "core.process.memory_map": { + "max_regions": CollectorSettingRule("integer", 1, 100_000), + "output_limit_bytes": CollectorSettingRule("integer", 1_024, 64 * 1024 * 1024), + "disk_safety_margin_bytes": CollectorSettingRule("integer", 0, MAXIMUM_RUN_OUTPUT_BYTES), + "dump_directory": CollectorSettingRule("workspace_path"), + }, +}) def _positive_integer(value: object, *, minimum: int, maximum: int) -> bool: @@ -26,23 +78,59 @@ def _bounded_number(value: object, *, minimum: float, maximum: float) -> bool: def _validate_collector_settings(settings: Mapping[str, Mapping[str, Any]]) -> None: - """Validate documented bounded settings before any worker receives them.""" - bandwidth = settings.get("core.network.bandwidth_sample", {}) - for key, maximum in (("sample_count", 10), ("interval_seconds", 5)): - if key in bandwidth and not _positive_integer(bandwidth[key], minimum=1, maximum=maximum): - raise PlanError(f"core.network.bandwidth_sample.{key} must be an integer from 1 to {maximum}") - - capture = settings.get("core.packet.packet_capture", {}) - if "packet_count" in capture and not _positive_integer(capture["packet_count"], minimum=1, maximum=10_000): - raise PlanError("core.packet.packet_capture.packet_count must be an integer from 1 to 10000") - if "timeout_seconds" in capture and not _bounded_number(capture["timeout_seconds"], minimum=1, maximum=60): - raise PlanError("core.packet.packet_capture.timeout_seconds must be a number from 1 to 60") - if "retry_window_seconds" in capture and not _bounded_number( - capture["retry_window_seconds"], minimum=0, maximum=60): - raise PlanError("core.packet.packet_capture.retry_window_seconds must be a number from 0 to 60") - if "interface" in capture and ( - not isinstance(capture["interface"], str) or not capture["interface"].strip()): - raise PlanError("core.packet.packet_capture.interface must be a non-empty string") + """Validate collector identities and all documented fields before worker launch.""" + for collector_id, values in settings.items(): + if not isinstance(collector_id, str) or not _COLLECTOR_ID.fullmatch(collector_id): + raise PlanError(f"collectors configuration contains an invalid collector ID: {collector_id!r}") + for key in values: + if not isinstance(key, str) or not _SETTING_NAME.fullmatch(key): + raise PlanError(f"{collector_id} contains an invalid setting name: {key!r}") + schema = _COLLECTOR_SETTING_SCHEMAS.get(collector_id) + if schema is None: + continue + unknown = sorted(set(values) - set(schema)) + if unknown: + raise PlanError(f"{collector_id} contains unsupported settings: {', '.join(unknown)}") + for key, value in values.items(): + rule = schema[key] + label = f"{collector_id}.{key}" + if rule.kind == "integer": + if not _positive_integer(value, minimum=int(rule.minimum), maximum=int(rule.maximum)): + raise PlanError(f"{label} must be an integer from {rule.minimum} to {rule.maximum}") + elif rule.kind == "number": + if not _bounded_number(value, minimum=rule.minimum, maximum=rule.maximum): + raise PlanError(f"{label} must be a number from {rule.minimum} to {rule.maximum}") + elif not isinstance(value, str) or not value.strip() or "\x00" in value: + raise PlanError(f"{label} must be a non-empty path or string") + elif rule.kind == "absolute_path" and not Path(value).is_absolute(): + raise PlanError(f"{label} must be an absolute filesystem path") + elif rule.kind == "workspace_path": + path = Path(value) + if path.is_absolute() or path.drive or ".." in path.parts: + raise PlanError(f"{label} must remain a relative collector-workspace path") + + +def _unique_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject ambiguous duplicate configuration keys instead of silently replacing them.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate configuration key {key!r}") + result[key] = value + return result + + +def _reject_json_constant(value: str) -> None: + """Reject non-finite nonstandard JSON values before settings can reach workers.""" + raise ValueError(f"non-finite configuration number {value!r}") + + +def _finite_json_number(value: str) -> float: + """Reject syntactically valid decimals that overflow the local float range.""" + result = float(value) + if not isfinite(result): + raise ValueError(f"non-finite configuration number {value!r}") + return result @dataclass(frozen=True, slots=True) @@ -87,11 +175,19 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi if not path.exists(): return default_config(project_root) try: - raw = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: + raw = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_unique_json_object, + parse_constant=_reject_json_constant, + parse_float=_finite_json_number, + ) + except (OSError, UnicodeDecodeError, ValueError) as error: raise PlanError(f"invalid configuration file {path}: {error}") from error if not isinstance(raw, dict): raise PlanError("configuration root must be a JSON object") + unknown_root = sorted(set(raw) - _ROOT_FIELDS) + if unknown_root: + raise PlanError(f"configuration contains unsupported root settings: {', '.join(unknown_root)}") schema_version = raw.get("schema_version", SCHEMA_VERSION) if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise PlanError("configuration schema_version must be an integer") @@ -101,6 +197,9 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi runtime_raw = raw.get("runtime", {}) if not isinstance(runtime_raw, dict): raise PlanError("runtime configuration must be an object") + unknown_runtime = sorted(set(runtime_raw) - _RUNTIME_FIELDS) + if unknown_runtime: + raise PlanError(f"runtime configuration contains unsupported settings: {', '.join(unknown_runtime)}") output_root_value = runtime_raw.get("output_root", project_root / "output" / "data") if not isinstance(output_root_value, (str, Path)) or not str(output_root_value).strip(): raise PlanError("runtime output_root must be a non-empty path string") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index fee8c9bd..ee2dddd4 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -616,6 +616,106 @@ def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> with self.assertRaisesRegex(PlanError, message): load_config(root) + def test_configuration_rejects_unknown_duplicate_and_non_finite_values(self) -> None: + """Ambiguous keys, typos, malformed IDs, and nonstandard JSON fail before planning.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid = ( + ('{"schema_version":4,"unexpected":true}', "unsupported root"), + ('{"schema_version":4,"runtime":{"worker_typo":2}}', "unsupported settings"), + ('{"schema_version":4,"schema_version":4}', "duplicate configuration key"), + ('{"schema_version":4,"runtime":{"maximum_workers":NaN}}', "non-finite"), + ('{"schema_version":4,"collectors":{"plugin.custom":{"value":1e999}}}', "non-finite"), + ('{"schema_version":4,"collectors":{"../escape":{}}}', "invalid collector ID"), + ('{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', "invalid setting name"), + ('{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', "unsupported settings"), + ) + for payload, message in invalid: + with self.subTest(payload=payload): + config_path.write_text(payload, encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + config_path.write_text( + '{"schema_version":4,"collectors":{"plugin.custom":{"extension_setting":"allowed"}}}', + encoding="utf-8", + ) + self.assertEqual("allowed", load_config(root).settings_for("plugin.custom")["extension_setting"]) + + def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) -> None: + """Traversal and sensitive inventory limits cannot silently coerce or expand in workers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid = ( + ("core.filesystem.system_drive_tree", {"max_entries": True}, "max_entries"), + ("core.filesystem.system_drive_tree", {"max_depth": 33}, "max_depth"), + ("core.filesystem.system_drive_listing", {"workers": 9}, "workers"), + ("core.filesystem.system_drive_listing", {"max_entries": "100"}, "max_entries"), + ("core.filesystem.sensitive_file_inventory", {"max_directories": 50_001}, "max_directories"), + ("core.filesystem.sensitive_file_inventory", {"max_matches": 0}, "max_matches"), + ("core.filesystem.sensitive_file_inventory", {"root": "relative/path"}, "absolute"), + ) + for collector_id, settings, message in invalid: + with self.subTest(collector_id=collector_id, settings=settings): + config_path.write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + valid = { + "core.filesystem.system_drive_tree": {"max_entries": 100, "max_depth": 3}, + "core.filesystem.system_drive_listing": {"max_entries": 200, "max_depth": 4, "workers": 2}, + "core.filesystem.sensitive_file_inventory": { + "root": str(root), "max_directories": 100, "max_matches": 10, + }, + } + config_path.write_text(json.dumps({"schema_version": 4, "collectors": valid}), encoding="utf-8") + configuration = load_config(root) + for collector_id, settings in valid.items(): + self.assertEqual(settings, configuration.settings_for(collector_id)) + + def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_paths(self) -> None: + """The existing metadata-only memory mapper rejects unsafe values before worker launch.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + collector_id = "core.process.memory_map" + invalid = ( + ({"max_regions": True}, "max_regions"), + ({"max_regions": 100_001}, "max_regions"), + ({"output_limit_bytes": 1_023}, "output_limit_bytes"), + ({"output_limit_bytes": 64 * 1024 * 1024 + 1}, "output_limit_bytes"), + ({"disk_safety_margin_bytes": -1}, "disk_safety_margin_bytes"), + ({"disk_safety_margin_bytes": "100"}, "disk_safety_margin_bytes"), + ({"dump_directory": "../outside"}, "collector-workspace"), + ({"dump_directory": str(root)}, "collector-workspace"), + ({"dump_directory": " "}, "non-empty"), + ) + for settings, message in invalid: + with self.subTest(settings=settings): + config_path.write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + settings = { + "max_regions": 500, + "output_limit_bytes": 4096, + "disk_safety_margin_bytes": 0, + "dump_directory": "bounded/maps", + } + config_path.write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + self.assertEqual(settings, load_config(root).settings_for(collector_id)) + def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> None: """Run requests must be immutable, typed declarations before a plan exists.""" collector_id = "core.system.system_info" From 77a5808c3df9d5391321f60824b6a753e22ddcff Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 25 Aug 2026 22:21:46 +0300 Subject: [PATCH 158/316] feat: migrate legacy configurations into validated v4 settings --- README.md | 7 +++ TODO.md | 2 +- logicytics/configuration.py | 68 +++++++++++++++++++++-- tests/test_core_functionality.py | 93 ++++++++++++++++++++++++++++++-- 4 files changed, 161 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ae321a49..42225104 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,13 @@ memory-map collector options are strictly typed and bounded before planning. Unknown engine fields, duplicate JSON keys, unsafe workspace paths, and invalid collector IDs fail closed; plugin-owned setting fields remain extensible. +Schema-version `3` JSON files are migrated in memory without modifying the source +file. Legacy `workers`/`worker_count`, `max_workers`, `output_root`, and +`collector_settings` aliases become their v4 runtime or collector equivalents; +the old `ACCESS/RUNS` default becomes `output/data`. Ambiguous aliases, unsafe +values, unsupported versions, and implicit plugin-selection fields are rejected. +Run manifests record `migrated_from_schema: 3` when this compatibility path runs. + ## Collector rules Core collectors must live at `core//.py`. Each module diff --git a/TODO.md b/TODO.md index 367ec0e2..3d2ca9ce 100644 --- a/TODO.md +++ b/TODO.md @@ -463,7 +463,7 @@ Windows platform allows. - [x] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` with documented collector membership. - [x] Allow explicit include/exclude collector selections to override a profile. -- [ ] Version the configuration schema and migrate older configuration files. +- [x] Version the configuration schema and migrate older configuration files. - [ ] Keep remote configuration optional, authenticated/validated, and out of the critical local collection path. - [ ] Never let a remote manifest silently add a collector to a user's run. diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 59d8f2b7..8706db22 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -22,6 +22,15 @@ _RUNTIME_FIELDS = frozenset({ "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", }) +_LEGACY_ROOT_FIELDS = _ROOT_FIELDS.union({ + "collector_settings", "workers", "worker_count", "max_workers", "output_root", + "package_completed_runs", "maximum_run_output_bytes", +}) +_LEGACY_RUNTIME_ALIASES = MappingProxyType({ + "workers": "default_max_workers", + "worker_count": "default_max_workers", + "max_workers": "maximum_workers", +}) @dataclass(frozen=True, slots=True) @@ -133,6 +142,47 @@ def _finite_json_number(value: str) -> float: return result +def _assign_migrated_runtime(runtime: dict[str, Any], target: str, value: Any, source: str) -> None: + """Refuse legacy aliases that would silently override another runtime setting.""" + if target in runtime: + raise PlanError(f"legacy configuration contains conflicting settings for {target}: {source}") + runtime[target] = value + + +def _migrate_v3_configuration(raw: Mapping[str, Any], project_root: Path) -> dict[str, Any]: + """Upgrade the explicitly supported v3 shape without writing or activating collectors.""" + unknown = sorted(set(raw) - _LEGACY_ROOT_FIELDS) + if unknown: + raise PlanError(f"legacy configuration contains unsupported root settings: {', '.join(unknown)}") + runtime_value = raw.get("runtime", {}) + if not isinstance(runtime_value, dict): + raise PlanError("legacy runtime configuration must be an object") + runtime: dict[str, Any] = {} + for key, value in runtime_value.items(): + _assign_migrated_runtime(runtime, _LEGACY_RUNTIME_ALIASES.get(key, key), value, f"runtime.{key}") + root_aliases = { + "workers": "default_max_workers", + "worker_count": "default_max_workers", + "max_workers": "maximum_workers", + "output_root": "output_root", + "package_completed_runs": "package_completed_runs", + "maximum_run_output_bytes": "maximum_run_output_bytes", + } + for source, target in root_aliases.items(): + if source in raw: + _assign_migrated_runtime(runtime, target, raw[source], source) + if "collectors" in raw and "collector_settings" in raw: + raise PlanError("legacy configuration contains conflicting collectors and collector_settings sections") + collectors = raw.get("collectors", raw.get("collector_settings", {})) + output_root = runtime.get("output_root") + if isinstance(output_root, str): + candidate = Path(output_root) + relative_parts = tuple(part.casefold() for part in candidate.parts) + if relative_parts == ("access", "runs") or candidate == project_root / "ACCESS" / "RUNS": + runtime["output_root"] = str(project_root / "output" / "data") + return {"schema_version": SCHEMA_VERSION, "runtime": runtime, "collectors": collectors} + + @dataclass(frozen=True, slots=True) class RuntimeSettings: """Engine-wide limits that apply before a collector is started.""" @@ -151,6 +201,7 @@ class AppConfig: schema_version: int runtime: RuntimeSettings collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) + migrated_from_schema: int | None = None def settings_for(self, collector_id: str) -> Mapping[str, Any]: """Return the isolated settings declared for one collector.""" @@ -185,14 +236,22 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError(f"invalid configuration file {path}: {error}") from error if not isinstance(raw, dict): raise PlanError("configuration root must be a JSON object") - unknown_root = sorted(set(raw) - _ROOT_FIELDS) - if unknown_root: - raise PlanError(f"configuration contains unsupported root settings: {', '.join(unknown_root)}") schema_version = raw.get("schema_version", SCHEMA_VERSION) if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise PlanError("configuration schema_version must be an integer") + migrated_from_schema: int | None = None + if schema_version == 3: + raw = _migrate_v3_configuration(raw, project_root) + migrated_from_schema = schema_version + schema_version = SCHEMA_VERSION if schema_version != SCHEMA_VERSION: - raise PlanError(f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION}") + raise PlanError( + f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION} " + "or supported legacy schema 3" + ) + unknown_root = sorted(set(raw) - _ROOT_FIELDS) + if unknown_root: + raise PlanError(f"configuration contains unsupported root settings: {', '.join(unknown_root)}") runtime_raw = raw.get("runtime", {}) if not isinstance(runtime_raw, dict): @@ -247,4 +306,5 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi maximum_run_output_bytes=maximum_run_output_bytes, ), collector_settings=collector_settings, + migrated_from_schema=migrated_from_schema, ) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index ee2dddd4..47604047 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -538,19 +538,104 @@ def test_command_runner_captures_output_and_parses_structured_levels(self) -> No self.assertEqual((("INFO", "collected"),), parse_level_messages(result.stdout)) def test_configuration_schema_version_is_enforced(self) -> None: - """Only the v4 configuration schema may be loaded for a v4 run.""" + """Current v4 and explicitly migrated v3 schemas are accepted; other versions are not.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) config_path = root / "logicytics.json" + for unsupported in (0, 1, 2, 5): + with self.subTest(schema_version=unsupported): + config_path.write_text(json.dumps({"schema_version": unsupported}), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "unsupported configuration schema_version"): + load_config(root) config_path.write_text('{"schema_version": 3}', encoding="utf-8") - with self.assertRaises(PlanError): - load_config(root) + migrated = load_config(root) + self.assertEqual(4, migrated.schema_version) + self.assertEqual(3, migrated.migrated_from_schema) config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") - self.assertEqual(4, load_config(root).schema_version) + current = load_config(root) + self.assertEqual(4, current.schema_version) + self.assertIsNone(current.migrated_from_schema) config_path.write_text('{"schema_version":4,"runtime":{"package_completed_runs":"yes"}}', encoding="utf-8") with self.assertRaisesRegex(PlanError, "package_completed_runs"): load_config(root) + def test_legacy_configuration_migrates_runtime_and_collector_aliases_without_writing(self) -> None: + """A supported v3 configuration migrates once in memory and preserves its source bytes.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + settings = {"core.packet.packet_capture": {"packet_count": 7, "timeout_seconds": 5}} + original = json.dumps({ + "schema_version": 3, + "workers": 3, + "max_workers": 6, + "output_root": "ACCESS/RUNS", + "collector_settings": settings, + }, indent=2) + config_path.write_text(original, encoding="utf-8") + configuration = load_config(root) + + self.assertEqual(4, configuration.schema_version) + self.assertEqual(3, configuration.migrated_from_schema) + self.assertEqual(3, configuration.runtime.default_max_workers) + self.assertEqual(6, configuration.runtime.maximum_workers) + self.assertEqual(root / "output" / "data", configuration.runtime.output_root) + self.assertEqual(settings["core.packet.packet_capture"], + configuration.settings_for("core.packet.packet_capture")) + self.assertEqual(original, config_path.read_text(encoding="utf-8")) + self.assertFalse((root / "output").exists()) + self.assertEqual(3, configuration.to_manifest_dict()["migrated_from_schema"]) + + def test_legacy_configuration_rejects_ambiguous_unsafe_and_plugin_enabling_migrations(self) -> None: + """Migration cannot override settings, weaken validation, or silently enable extensions.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid = ( + ({"workers": 2, "worker_count": 3}, "conflicting settings"), + ({"workers": 2, "runtime": {"default_max_workers": 2}}, "conflicting settings"), + ({"runtime": {"workers": 2, "worker_count": 3}}, "conflicting settings"), + ({"collectors": {}, "collector_settings": {}}, "conflicting collectors"), + ({"enable_plugins": True}, "unsupported root"), + ({"workers": True}, "worker limits"), + ({"collector_settings": {"core.process.memory_map": {"dump_directory": "../outside"}}}, + "collector-workspace"), + ({"collector_settings": {"core.packet.packet_capture": {"packet_count": 0}}}, "packet_count"), + ) + for legacy, message in invalid: + with self.subTest(legacy=legacy): + config_path.write_text(json.dumps({"schema_version": 3, **legacy}), encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + + def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evidence(self) -> None: + """Migrated collection stays isolated, packages normally, and never moves old evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + legacy = root / "ACCESS" / "RUNS" / "previous.txt" + legacy.parent.mkdir(parents=True) + legacy.write_text("keep legacy evidence", encoding="utf-8") + config_path = root / "logicytics.json" + original = json.dumps({"schema_version": 3, "worker_count": 1, "output_root": "ACCESS/RUNS"}) + config_path.write_text(original, encoding="utf-8") + configuration = load_config(root) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + + self.assertEqual(root / "output" / "data", outcome.run_directory.parent) + self.assertEqual(3, outcome.manifest.configuration["migrated_from_schema"]) + self.assertEqual("keep legacy evidence", legacy.read_text(encoding="utf-8")) + self.assertEqual(original, config_path.read_text(encoding="utf-8")) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged = json.loads(archive.read("manifest.json")) + self.assertEqual(4, packaged["configuration"]["schema_version"]) + self.assertEqual(3, packaged["configuration"]["migrated_from_schema"]) + def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: """Defaults and loaded settings share output/data while explicit roots remain supported.""" with tempfile.TemporaryDirectory() as temporary: From 75eccb3e707253f8faf2146bd599a24ea448af37 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 01:04:19 +0300 Subject: [PATCH 159/316] feat: expose a verified public run lifecycle API --- README.md | 26 +++ TODO.md | 4 +- logicytics/__init__.py | 24 +++ logicytics/api.py | 285 +++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 211 +++++++++++++++++++++++ 5 files changed, 548 insertions(+), 2 deletions(-) create mode 100644 logicytics/api.py diff --git a/README.md b/README.md index 42225104..47c61804 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,32 @@ python -m logicytics run --profile standard --acknowledge-authorization ` Plugin collectors never join any profile unless explicitly included or enabled with `--plugins`. Unknown profile names are rejected before collection begins. +## Public Python API + +Importing `logicytics` does not start collection, create output directories, or +load the runtime supervisor. Application services are loaded only when their +public names are requested: + +```python +from pathlib import Path + +from logicytics import RunRequest, load_configuration, plan_run, query_run, read_artifact, run_collection + +root = Path.cwd() +configuration = load_configuration(root) +request = RunRequest(max_workers=1, acknowledge_authorization=True) +plan = plan_run(root, request, configuration=configuration) +outcome = run_collection(root, request, configuration=configuration) +snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) +contents = read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration) +``` + +Planning enforces strict core/plugin preflight and configured worker bounds +without creating evidence. Run queries validate the manifest, collector-owned +artifact catalog, and configured output boundaries. Artifact reads are bounded +(16 MiB by default, never more than 64 MiB) and verify the exact registered +bytes against their manifest SHA-256 before returning evidence. + ## Configuration An optional project-root `logicytics.json` uses schema version `4` and separates diff --git a/TODO.md b/TODO.md index 3d2ca9ce..5e839a1c 100644 --- a/TODO.md +++ b/TODO.md @@ -501,10 +501,10 @@ Windows platform allows. ### Improve the public API and testability -- [ ] Make the package importable without starting a collection, opening files, or +- [x] Make the package importable without starting a collection, opening files, or requiring Windows-only APIs at import time. - [ ] Replace singleton-heavy APIs with dependency injection where state matters. -- [ ] Keep a small stable public API: configuration, planning, running, querying +- [x] Keep a small stable public API: configuration, planning, running, querying run status, and reading artifacts. - [ ] Keep platform adapters mockable so collectors can be tested on non-Windows development machines. diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 8b851d99..02936b39 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -11,10 +11,26 @@ PluginCollector, ResourceClass, RunRequest, + RunStatus, Specialty, ValidationResult, ) +_APPLICATION_EXPORTS = frozenset({ + "CollectorSnapshot", "RunSnapshot", "load_configuration", "plan_run", "query_run", "read_artifact", + "run_collection", +}) + + +def __getattr__(name: str): + """Load application services only when callers explicitly request them.""" + if name in _APPLICATION_EXPORTS: + from logicytics import api + + return getattr(api, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "CONTRACT_VERSION", "Capability", @@ -22,10 +38,18 @@ "CollectionEstimate", "CollectorMetadata", "CollectorResult", + "CollectorSnapshot", "CoreCollector", "PluginCollector", "ResourceClass", "RunRequest", + "RunSnapshot", + "RunStatus", "Specialty", "ValidationResult", + "load_configuration", + "plan_run", + "query_run", + "read_artifact", + "run_collection", ] diff --git a/logicytics/api.py b/logicytics/api.py new file mode 100644 index 00000000..312a6e14 --- /dev/null +++ b/logicytics/api.py @@ -0,0 +1,285 @@ +"""Small, side-effect-free public application API for v4 collection runs.""" + +from __future__ import annotations + +import hashlib +import json +import re +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path, PurePosixPath +from typing import Any + +from logicytics.configuration import AppConfig, load_config +from logicytics.contracts import Artifact, CollectorStatus, RunRequest, RunStatus +from logicytics.discovery import preflight +from logicytics.errors import ArtifactError, PlanError +from logicytics.planner import RunPlan, build_plan +from logicytics.runtime import RunOutcome, RunSupervisor + +_RUN_ID = re.compile(r"run-[0-9a-f]{32}") +_ARTIFACT_ID = re.compile(r"artifact\.[0-9a-f]{32}") +_COLLECTOR_ID = re.compile(r"(?:core|plugin)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?") +_MAXIMUM_ARTIFACT_READ_BYTES = 64 * 1024 * 1024 +_DEFAULT_ARTIFACT_READ_BYTES = 16 * 1024 * 1024 + + +@dataclass(frozen=True, slots=True) +class CollectorSnapshot: + """Immutable public status for one collector recorded in a persisted run.""" + + collector_id: str + status: str + + +@dataclass(frozen=True, slots=True) +class RunSnapshot: + """Immutable, manifest-validated view of one current or completed run.""" + + run_id: str + status: RunStatus + run_directory: Path + manifest_path: Path + collectors: tuple[CollectorSnapshot, ...] + artifacts: tuple[Artifact, ...] + finished_at: str | None + + +def _unique_manifest_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject ambiguous persisted manifest keys instead of replacing prior values.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate run manifest key {key!r}") + result[key] = value + return result + + +def load_configuration(project_root: Path | str, config_path: Path | str | None = None) -> AppConfig: + """Load strictly validated configuration without creating collection output.""" + root = Path(project_root).resolve() + selected = None if config_path is None else Path(config_path) + if selected is not None and not selected.is_absolute(): + selected = root / selected + return load_config(root, selected) + + +def _configuration( + project_root: Path | str, + configuration: AppConfig | None, + config_path: Path | str | None, +) -> tuple[Path, AppConfig]: + """Resolve one unambiguous, typed configuration source.""" + root = Path(project_root).resolve() + if configuration is not None and config_path is not None: + raise PlanError("provide either configuration or config_path, not both") + if configuration is not None and not isinstance(configuration, AppConfig): + raise PlanError("configuration must be a validated AppConfig instance") + return root, configuration if configuration is not None else load_configuration(root, config_path) + + +def plan_run( + project_root: Path | str, + request: RunRequest, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, +) -> RunPlan: + """Preflight and resolve a strict run without starting collectors or writing output.""" + root, settings = _configuration(project_root, configuration, config_path) + if not isinstance(request, RunRequest): + raise PlanError("request must be an immutable RunRequest instance") + if request.max_workers > settings.runtime.maximum_workers: + raise PlanError("requested workers exceed configured maximum_workers") + return build_plan(preflight(root), request) + + +def run_collection( + project_root: Path | str, + request: RunRequest, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, +) -> RunOutcome: + """Execute only a strictly validated, authorized, independently isolated run.""" + root, settings = _configuration(project_root, configuration, config_path) + plan = plan_run(root, request, configuration=settings) + return RunSupervisor(root, settings).run(plan) + + +def _artifact(raw: object, collector_id: str) -> Artifact: + """Reconstruct one strict artifact while enforcing collector-owned paths.""" + if not isinstance(raw, dict): + raise PlanError("run manifest contains a malformed registered artifact") + values = dict(raw) + transformations = values.get("transformations") + if not isinstance(transformations, list): + raise PlanError("run manifest artifact transformations must be an array") + values["transformations"] = tuple(transformations) + try: + artifact = Artifact(**values) + except (TypeError, ValueError) as error: + raise PlanError(f"run manifest contains an invalid registered artifact: {error}") from error + relative = PurePosixPath(artifact.relative_path) + owner = collector_id.replace(".", "_") + if ( + artifact.collector_id != collector_id + or "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != owner + or artifact.name != relative.name + ): + raise PlanError("run manifest artifact violates collector ownership or path boundaries") + return artifact + + +def _manifest_timestamp(value: object, field: str, *, optional: bool = False) -> str | None: + """Require timezone-aware persisted timestamps, allowing null only when declared.""" + if value is None and optional: + return None + if not isinstance(value, str): + raise PlanError(f"run manifest {field} must be a timezone-aware timestamp") + try: + timestamp = datetime.fromisoformat(value) + except ValueError as error: + raise PlanError(f"run manifest {field} must be a timezone-aware timestamp") from error + if timestamp.tzinfo is None: + raise PlanError(f"run manifest {field} must be a timezone-aware timestamp") + return value + + +def query_run( + project_root: Path | str, + run_id: str, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, +) -> RunSnapshot: + """Read a run-owned manifest and return immutable, ownership-validated status.""" + _, settings = _configuration(project_root, configuration, config_path) + if not isinstance(run_id, str) or _RUN_ID.fullmatch(run_id) is None: + raise PlanError("run_id must be a canonical run identifier") + output_root = settings.runtime.output_root.resolve() + run_directory = output_root / run_id + manifest_path = run_directory / "manifest.json" + try: + if run_directory.resolve(strict=True) != run_directory: + raise PlanError("run directory escapes its configured output root") + if manifest_path.resolve(strict=True) != manifest_path or not manifest_path.is_file(): + raise PlanError("run manifest escapes its run-owned directory") + payload = json.loads(manifest_path.read_text(encoding="utf-8"), object_pairs_hook=_unique_manifest_object) + except (OSError, UnicodeDecodeError, ValueError) as error: + raise PlanError(f"unable to read run manifest for {run_id}: {error}") from error + if not isinstance(payload, dict) or payload.get("run_id") != run_id: + raise PlanError("run manifest identity does not match its owned directory") + _manifest_timestamp(payload.get("requested_at"), "requested_at") + if not isinstance(payload.get("status"), str): + raise PlanError("run manifest contains an unsupported run status") + try: + status = RunStatus(payload.get("status")) + except ValueError as error: + raise PlanError("run manifest contains an unsupported run status") from error + records = payload.get("collectors") + catalog = payload.get("artifact_catalog") + if not isinstance(records, list) or not isinstance(catalog, list): + raise PlanError("run manifest must contain collector and artifact catalogs") + + collectors: list[CollectorSnapshot] = [] + artifacts: list[Artifact] = [] + collector_ids: set[str] = set() + artifact_ids: set[str] = set() + expected_catalog: list[dict[str, Any]] = [] + valid_collector_statuses = {"planned", "running", *(item.value for item in CollectorStatus)} + for record in records: + if not isinstance(record, dict): + raise PlanError("run manifest contains a malformed collector record") + collector_id = record.get("id") + collector_status = record.get("status") + if ( + not isinstance(collector_id, str) + or _COLLECTOR_ID.fullmatch(collector_id) is None + or collector_id in collector_ids + or not isinstance(collector_status, str) + or collector_status not in valid_collector_statuses + or not isinstance(record.get("artifacts"), list) + ): + raise PlanError("run manifest contains an invalid or duplicate collector record") + collector_ids.add(collector_id) + collectors.append(CollectorSnapshot(collector_id, collector_status)) + for raw in record["artifacts"]: + artifact = _artifact(raw, collector_id) + if artifact.id in artifact_ids: + raise PlanError("run manifest contains duplicate registered artifact IDs") + artifact_ids.add(artifact.id) + artifacts.append(artifact) + expected_catalog.append({**raw, "producer_status": collector_status}) + if payload.get("resolved_plan") != [item.collector_id for item in collectors]: + raise PlanError("run manifest collector records do not match the resolved plan") + if catalog != expected_catalog: + raise PlanError("run manifest artifact catalog does not match collector-owned evidence") + if status not in {RunStatus.PLANNED, RunStatus.RUNNING} and payload.get("total_artifact_bytes") != sum( + artifact.size_bytes for artifact in artifacts + ): + raise PlanError("run manifest total_artifact_bytes does not match registered evidence") + finished_at = _manifest_timestamp(payload.get("finished_at"), "finished_at", optional=True) + if status not in {RunStatus.PLANNED, RunStatus.RUNNING} and finished_at is None: + raise PlanError("finalized run manifest must contain finished_at") + return RunSnapshot( + run_id=run_id, + status=status, + run_directory=run_directory, + manifest_path=manifest_path, + collectors=tuple(collectors), + artifacts=tuple(artifacts), + finished_at=finished_at, + ) + + +def read_artifact( + project_root: Path | str, + run_id: str, + artifact_id: str, + *, + maximum_bytes: int = _DEFAULT_ARTIFACT_READ_BYTES, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, +) -> bytes: + """Read bounded, registered evidence only after ownership and SHA-256 verification.""" + if not isinstance(artifact_id, str) or _ARTIFACT_ID.fullmatch(artifact_id) is None: + raise ArtifactError("artifact_id must be a canonical registered artifact identifier") + if ( + not isinstance(maximum_bytes, int) + or isinstance(maximum_bytes, bool) + or not 1 <= maximum_bytes <= _MAXIMUM_ARTIFACT_READ_BYTES + ): + raise ArtifactError("maximum_bytes must be an integer from 1 to 67108864") + snapshot = query_run(project_root, run_id, configuration=configuration, config_path=config_path) + artifact = next((item for item in snapshot.artifacts if item.id == artifact_id), None) + if artifact is None: + raise ArtifactError("artifact is not registered in the selected run manifest") + if artifact.size_bytes > maximum_bytes: + raise ArtifactError("registered artifact exceeds the requested bounded read limit") + root = snapshot.run_directory / "artifacts" + source = root.joinpath(*PurePosixPath(artifact.relative_path).parts) + owner = root / artifact.collector_id.replace(".", "_") + try: + if root.resolve(strict=True) != root or owner.resolve(strict=True) != owner: + raise ValueError("artifact ownership roots must not be redirected") + resolved = source.resolve(strict=True) + resolved.relative_to(owner) + if not source.is_file(): + raise ArtifactError("registered artifact must be a regular run-owned file") + with source.open("rb") as stream: + contents = stream.read(maximum_bytes + 1) + except (OSError, ValueError) as error: + raise ArtifactError("registered artifact escapes its collector-owned store or cannot be read") from error + if ( + len(contents) > maximum_bytes + or len(contents) != artifact.size_bytes + or hashlib.sha256(contents).hexdigest() != artifact.sha256 + ): + raise ArtifactError("registered artifact failed manifest size or SHA-256 verification") + return contents diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 47604047..a4725946 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -8,15 +8,18 @@ import json import os import subprocess +import sys import tempfile import unittest import zipfile from concurrent.futures import ThreadPoolExecutor +from dataclasses import FrozenInstanceError from datetime import datetime from pathlib import Path from unittest.mock import patch from logicytics import packaging +from logicytics import load_configuration, plan_run, query_run, read_artifact, run_collection from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.cli import _parser, _request, main @@ -33,6 +36,7 @@ CollectorStatus, ResourceClass, RunRequest, + RunStatus, Specialty, ) from logicytics.discovery import PreflightReport, preflight @@ -129,6 +133,213 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_public_package_import_does_not_start_runtime_or_create_files(self) -> None: + """Importing contracts exposes lazy application services without host or disk side effects.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project_root = Path(__file__).resolve().parents[1] + environment = dict(os.environ) + environment["PYTHONPATH"] = str(project_root) + probe = ( + "import pathlib, sys; " + "before = tuple(pathlib.Path.cwd().iterdir()); " + "import logicytics; " + "assert 'logicytics.runtime' not in sys.modules; " + "assert {'load_configuration', 'plan_run', 'run_collection', 'query_run', 'read_artifact'}" + ".issubset(logicytics.__all__); " + "assert tuple(pathlib.Path.cwd().iterdir()) == before" + ) + result = subprocess.run( + [sys.executable, "-c", probe], + cwd=root, + env=environment, + capture_output=True, + check=False, + text=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], list(root.iterdir())) + + def test_public_planning_is_read_only_bounded_and_keeps_plugins_opt_in(self) -> None: + """The application planner validates configuration and extensions without starting work.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text(_COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "example_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text( + _COLLECTOR.replace("CoreCollector", "PluginCollector") + .replace("SystemInfoCollector", "ExamplePluginCollector") + .replace("core.system.system_info", "plugin.example_plugin"), + encoding="utf-8", + ) + (root / "settings.json").write_text( + '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', + encoding="utf-8", + ) + configuration = load_configuration(root, "settings.json") + standard = plan_run(root, RunRequest(max_workers=1), configuration=configuration) + selected = plan_run( + root, + RunRequest(max_workers=1, include=("plugin.example_plugin",)), + configuration=configuration, + ) + + self.assertEqual(["core.system.system_info"], [item.metadata.id for item in standard.collectors]) + self.assertEqual( + ["core.system.system_info", "plugin.example_plugin"], + [item.metadata.id for item in selected.collectors], + ) + with self.assertRaisesRegex(PlanError, "maximum_workers"): + plan_run(root, RunRequest(max_workers=3), configuration=configuration) + with self.assertRaisesRegex(PlanError, "either configuration or config_path"): + plan_run(root, RunRequest(max_workers=1), configuration=configuration, config_path="settings.json") + plugin_path.write_text('"""Invalid optional plugin."""\n', encoding="utf-8") + quarantined = plan_run(root, RunRequest(max_workers=1), configuration=configuration) + self.assertEqual(["core.system.system_info"], [item.metadata.id for item in quarantined.collectors]) + with self.assertRaises(PreflightError): + plan_run( + root, + RunRequest(max_workers=1, include=("plugin.example_plugin",)), + configuration=configuration, + ) + collector_path.write_text('"""Invalid shipped core collector."""\n', encoding="utf-8") + with self.assertRaises(PreflightError): + plan_run(root, RunRequest(max_workers=1), configuration=configuration) + self.assertFalse(configuration.runtime.output_root.exists()) + + def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> None: + """The complete public lifecycle retains isolation and immutable manifest-backed status.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + configuration = load_configuration(root) + request = RunRequest(max_workers=1, acknowledge_authorization=True) + outcome = run_collection(root, request, configuration=configuration) + snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) + + self.assertEqual(RunStatus.SUCCEEDED, snapshot.status) + self.assertEqual(outcome.run_directory, snapshot.run_directory) + self.assertEqual(outcome.manifest_path, snapshot.manifest_path) + self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) + self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) + self.assertEqual(1, len(snapshot.artifacts)) + self.assertEqual( + b"ok" + os.linesep.encode("ascii"), + read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration), + ) + with self.assertRaises(FrozenInstanceError): + snapshot.status = RunStatus.FAILED + + def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links(self) -> None: + """Persisted status lookup cannot leave its configured run or trust a forged manifest.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) + original = outcome.manifest_path.read_text(encoding="utf-8") + + for invalid in ("../outside", "run-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "run-short"): + with self.subTest(run_id=invalid): + with self.assertRaisesRegex(PlanError, "canonical run identifier"): + query_run(root, invalid) + payload = json.loads(original) + payload["run_id"] = "run-" + "0" * 32 + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "identity"): + query_run(root, outcome.manifest.run_id) + invalid_manifests = ( + ("status", [], "unsupported run status"), + ("resolved_plan", ["core.system.forged"], "resolved plan"), + ("total_artifact_bytes", 999, "total_artifact_bytes"), + ("requested_at", "not-a-timestamp", "requested_at"), + ("finished_at", None, "finished_at"), + ) + for field, value, message in invalid_manifests: + with self.subTest(field=field): + payload = json.loads(original) + payload[field] = value + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + query_run(root, outcome.manifest.run_id) + payload = json.loads(original) + payload["collectors"][0]["id"] = "../outside" + payload["resolved_plan"] = ["../outside"] + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "collector record"): + query_run(root, outcome.manifest.run_id) + outcome.manifest_path.write_text( + '{"run_id":"' + outcome.manifest.run_id + '","run_id":"duplicate"}', + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "duplicate run manifest key"): + query_run(root, outcome.manifest.run_id) + outcome.manifest_path.write_text(original, encoding="utf-8") + outside = root / "outside-manifest.json" + outside.write_text(original, encoding="utf-8") + outcome.manifest_path.unlink() + outcome.manifest_path.symlink_to(outside) + with self.assertRaisesRegex(PlanError, "escapes its run-owned directory"): + query_run(root, outcome.manifest.run_id) + + def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_access(self) -> None: + """Only exact, bounded, manifest-cataloged bytes from the producing collector may be read.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(_COLLECTOR, encoding="utf-8") + outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) + artifact = outcome.manifest.artifact_list()[0] + stored = outcome.run_directory / "artifacts" / artifact.relative_path + original = stored.read_bytes() + manifest_source = outcome.manifest_path.read_text(encoding="utf-8") + + with self.assertRaisesRegex(ArtifactError, "canonical"): + read_artifact(root, outcome.manifest.run_id, "../outside") + with self.assertRaisesRegex(ArtifactError, "not registered"): + read_artifact(root, outcome.manifest.run_id, "artifact." + "0" * 32) + for invalid_limit in (True, 0, 67_108_865): + with self.subTest(maximum_bytes=invalid_limit): + with self.assertRaisesRegex(ArtifactError, "maximum_bytes"): + read_artifact(root, outcome.manifest.run_id, artifact.id, maximum_bytes=invalid_limit) + with self.assertRaisesRegex(ArtifactError, "bounded read limit"): + read_artifact(root, outcome.manifest.run_id, artifact.id, maximum_bytes=1) + stored.write_bytes(b"bad") + with self.assertRaisesRegex(ArtifactError, "SHA-256 verification"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + stored.write_bytes(original) + payload = json.loads(manifest_source) + payload["artifact_catalog"][0]["producer_status"] = "failed" + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "artifact catalog"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + outcome.manifest_path.write_text(manifest_source, encoding="utf-8") + outside = root / "outside-evidence.txt" + outside.write_bytes(original) + stored.unlink() + stored.symlink_to(outside) + with self.assertRaisesRegex(ArtifactError, "collector-owned store"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + stored.unlink() + owner = stored.parent + owner.rmdir() + redirected_owner = root / "redirected-owner" + redirected_owner.mkdir() + (redirected_owner / stored.name).write_bytes(original) + owner.symlink_to(redirected_owner, target_is_directory=True) + with self.assertRaisesRegex(ArtifactError, "collector-owned store"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + def test_sysinternals_archive_lifecycle_honors_ignore_and_extracts_safely(self) -> None: """The local bundle must honor opt-out and extract only within its target directory.""" with tempfile.TemporaryDirectory() as temporary: From d6ee6c9a74cce2fa5ddd56206bc4267ae74289b5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 16:50:25 +0300 Subject: [PATCH 160/316] fix: block collector control-plane subprocess escapes --- README.md | 5 ++ TODO.md | 2 +- logicytics/discovery.py | 45 +++++++++++++++ logicytics/runtime.py | 98 +++++++++++++++++++++++++++++++- tests/test_core_functionality.py | 93 ++++++++++++++++++++++++++++++ 5 files changed, 240 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 47c61804..04b56f89 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,11 @@ from `CoreCollector`, provide documented and typed `metadata`, `validate`, `collect`, and `cleanup` methods, and use a matching ID/specialty. Plugins follow the equivalent `PluginCollector` contract. +Collectors may import the public collector contracts, but not CLI, planning, +runtime, packaging, configuration, or public application-control services. Even +with an approved subprocess capability, workers cannot invoke Logicytics, another +collector, repository/package managers, or system reboot/shutdown commands. + Malformed core collectors block a run. Malformed unselected plugins are quarantined and listed by preflight; malformed explicitly selected plugins block the requested run. See [TODO.md](TODO.md) for the complete v4 recreation plan. diff --git a/TODO.md b/TODO.md index 5e839a1c..da1caea5 100644 --- a/TODO.md +++ b/TODO.md @@ -336,7 +336,7 @@ Windows platform allows. - [x] Capture subprocess trees so child processes do not survive a failed collector. - [ ] Use OS/process isolation as the default boundary; treat Python-level import restrictions as validation and defense-in-depth, not as a complete sandbox. -- [ ] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter +- [x] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter configuration, install packages, update the repository, or launch another collector through the public context API. - [x] Provide a supervisor heartbeat and last-progress timestamp for every worker. diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 5ab70660..562927c2 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -16,6 +16,11 @@ _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} +_APPLICATION_IMPORTS = { + "CollectorSnapshot", "RunSnapshot", "api", "artifacts", "cli", "configuration", "discovery", "environment", + "load_configuration", "manifest", "packaging", "plan_run", "planner", "query_run", "read_artifact", "runtime", + "run_collection", "validation_worker", +} @dataclass(frozen=True, slots=True) @@ -122,6 +127,7 @@ def _diagnostic_rule(message: str, *, runtime: bool) -> str: ("docstring", "static.docstring"), ("print", "static.console_output"), ("import-time", "static.import_time_side_effect"), + ("application import", "static.engine_boundary"), ("top-level", "static.top_level_statement"), ("inherit", "static.inheritance"), ("collector class", "static.class_name"), @@ -271,6 +277,44 @@ def _validate_import_time_expressions(tree: ast.Module, candidate: CollectorCand ) +def _validate_engine_boundary_imports(tree: ast.Module, candidate: CollectorCandidate) -> None: + """Keep collector modules on contracts only, never application or orchestration services.""" + application_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name.startswith("logicytics.") and alias.name != "logicytics.contracts": + candidate.static_errors.append( + f"forbidden application import: {alias.name} (line {node.lineno})" + ) + elif alias.name == "logicytics": + application_aliases.add(alias.asname or "logicytics") + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + if module.startswith("logicytics.") and module != "logicytics.contracts": + candidate.static_errors.append( + f"forbidden application import: {module} (line {node.lineno})" + ) + elif module == "logicytics": + forbidden = sorted( + alias.name for alias in node.names if alias.name in _APPLICATION_IMPORTS + ) + if forbidden: + candidate.static_errors.append( + f"forbidden application import: {', '.join(forbidden)} (line {node.lineno})" + ) + for node in ast.walk(tree): + if ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in application_aliases + and node.attr in _APPLICATION_IMPORTS + ): + candidate.static_errors.append( + f"forbidden application import: {node.value.id}.{node.attr} (line {node.lineno})" + ) + + def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: expected_class = _pascal_case(path.name) candidate = CollectorCandidate(path=path, kind=kind, expected_class=expected_class) @@ -305,6 +349,7 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: _validate_class_shape(public_classes[0], candidate) _validate_import_time_expressions(tree, candidate) + _validate_engine_boundary_imports(tree, candidate) for item in tree.body: if isinstance(item, ast.Expr) and isinstance(item.value, ast.Constant): continue diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 1f941950..2f3d3d1e 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -9,6 +9,8 @@ import multiprocessing import os import queue +import re +import shlex import signal import shutil import socket @@ -72,6 +74,18 @@ class _WorkerMutationGuard: _PATH_EVENTS = {"os.mkdir", "os.remove", "os.rmdir", "os.chmod", "os.chown", "os.utime", "os.truncate"} _DOUBLE_PATH_EVENTS = {"os.rename", "os.link", "os.symlink"} _BLOCKED_EVENTS = {"os.chdir", "os.putenv", "os.unsetenv", "os.system"} + _FORBIDDEN_COMMANDS = { + "choco", "git", "npm", "pip", "pip3", "pnpm", "poetry", "shutdown", "uv", "winget", "yarn", + } + _FORBIDDEN_TOOL_REFERENCE = re.compile( + r"(? None: if private_key and Capability.PRIVATE_KEYS not in self.capabilities: raise PermissionError("collector requires declared private_keys capability") + @staticmethod + def _subprocess_tokens(arguments: tuple[object, ...]) -> tuple[str, ...]: + """Normalize cross-platform subprocess audit arguments without executing a shell.""" + executable = arguments[0] if arguments else None + command = arguments[1] if len(arguments) > 1 else None + if isinstance(command, (list, tuple)): + tokens = tuple(str(item) for item in command) + elif isinstance(command, str): + try: + tokens = tuple(shlex.split(command, posix=os.name != "nt")) + except ValueError: + tokens = tuple(command.split()) + else: + tokens = () + if tokens: + return tokens + return (str(executable),) if isinstance(executable, (str, bytes, os.PathLike)) else () + + @staticmethod + def _command_stem(value: str) -> str: + """Return a case-insensitive executable label without Windows wrapper suffixes.""" + name = Path(value.strip("\"'")).name.casefold() + for suffix in (".exe", ".com", ".cmd", ".bat"): + if name.endswith(suffix): + return name[:-len(suffix)] + return name + + def _prohibited_subprocess(self, arguments: tuple[object, ...]) -> str | None: + """Classify commands forbidden even when ordinary subprocess access was approved.""" + normalized = tuple(token.strip("\"'") for token in self._subprocess_tokens(arguments)) + stems = tuple(self._command_stem(token) for token in normalized) + if any(stem in self._FORBIDDEN_COMMANDS for stem in stems): + stem = next(stem for stem in stems if stem in self._FORBIDDEN_COMMANDS) + return "system_power" if stem == "shutdown" else "repository_or_package_management" + if any(re.fullmatch(r"pip(?:\d+(?:\.\d+)*)?", stem) for stem in stems): + return "repository_or_package_management" + for index, token in enumerate(normalized[:-1]): + if token == "-m": + module = normalized[index + 1].casefold() + if module == "pip" or module.startswith("pip.") or module == "ensurepip": + return "repository_or_package_management" + if module == "logicytics" or module.startswith("logicytics."): + return "main_application" + command_text = " ".join(normalized) + if re.search(r"(? None: if not self.active: return - if event == "subprocess.Popen" and Capability.SUBPROCESS not in self.capabilities: - raise PermissionError("collector requires declared subprocess capability") + if event == "subprocess.Popen": + if Capability.SUBPROCESS not in self.capabilities: + raise PermissionError("collector requires declared subprocess capability") + if prohibited := self._prohibited_subprocess(arguments): + raise PermissionError(f"collector subprocess command is prohibited: {prohibited}") if event.startswith("socket.") and event in { "socket.__new__", "socket.bind", "socket.connect", "socket.sendto", "socket.getaddrinfo" }: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index a4725946..f024ab7f 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -3092,6 +3092,99 @@ def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> N outcome = RunSupervisor(root, default_config(root)).run(plan) self.assertEqual("succeeded", outcome.manifest.collectors[0].status) + def test_worker_blocks_application_update_power_and_peer_collector_commands_without_stopping_peers(self) -> None: + """Subprocess approval cannot escape the collector role or affect an independent worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + peer_path = core_directory / "z_independent.py" + peer_path.write_text(_delayed_collector_source("z_independent", 0.0), encoding="utf-8") + protected_configuration = root / "logicytics.json" + attempts = ( + ([sys.executable, "-m", "logicytics", "preflight"], "main_application"), + ([sys.executable, "-m", "pip", "--version"], "repository_or_package_management"), + (["git", "--version"], "repository_or_package_management"), + (["shutdown", "/?"], "system_power"), + (["cmd", "/c", "shutdown", "/?"], "system_power"), + ( + [ + sys.executable, + "-c", + f"from pathlib import Path; Path({str(protected_configuration)!r}).write_text('bad')", + ], + "configuration_mutation", + ), + ([sys.executable, str(peer_path)], "collector_launch"), + ) + for command, category in attempts: + with self.subTest(command=command): + attacker = _delayed_collector_source("a_attacker", 0.0).replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SUBPROCESS,),', + ).replace( + ' output = context.workspace / "system.txt"', + f" subprocess.run({command!r}, check=True)\n" + ' output = context.workspace / "system.txt"', + ) + (core_directory / "a_attacker.py").write_text(attacker, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("failed", records["core.system.a_attacker"].status) + self.assertIn( + f"collector subprocess command is prohibited: {category}", + "\n".join(records["core.system.a_attacker"].errors), + ) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertEqual("partial", outcome.manifest.status.value) + self.assertFalse(protected_configuration.exists()) + + def test_preflight_rejects_collector_imports_of_application_and_orchestration_services(self) -> None: + """Collectors may import public contracts but never the main application control surface.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + attempts = ( + "import logicytics.cli", + "from logicytics import run_collection", + "from logicytics import packaging", + "import logicytics\nlogicytics.run_collection", + "import logicytics\nlogicytics.configuration", + "from logicytics.api import query_run", + ) + for statement in attempts: + with self.subTest(statement=statement): + collector_path.write_text( + _COLLECTOR.replace("from pathlib import Path\n", f"from pathlib import Path\n{statement}\n"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("forbidden application import", "\n".join(report.invalid[0].static_errors)) + diagnostic = next( + item for item in report.invalid[0].diagnostics + if "forbidden application import" in item.message + ) + self.assertEqual("static.engine_boundary", diagnostic.rule) + @unittest.skipUnless(os.name == "nt", "Windows collector subprocess-tree containment") def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peers(self) -> None: """A timed-out worker cannot leave its child alive or terminate independent collectors.""" From 248313cfa4149d8a3ca9423bd2100b7dee3c3bcd Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 17:06:01 +0300 Subject: [PATCH 161/316] feat: separate forensic package sections --- README.md | 12 ++ TODO.md | 2 +- core/browser/browser_data_backup.py | 6 +- core/filesystem/sensitive_file_inventory.py | 6 +- core/media/media_backup.py | 6 +- core/registry/hklm_backup.py | 9 +- core/ssh/ssh_backup.py | 9 +- core/system/windows_system_data_backup.py | 6 +- core/wireless/wifi_profile_keys.py | 8 +- logicytics/__init__.py | 2 + logicytics/api.py | 3 +- logicytics/artifacts.py | 14 +- logicytics/contracts.py | 25 +++ logicytics/manifest.py | 13 +- logicytics/packaging.py | 66 ++++++-- logicytics/runtime.py | 14 +- tests/test_core_functionality.py | 164 +++++++++++++++----- 17 files changed, 278 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 04b56f89..5a0545b4 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,14 @@ output/ # ignored generated evidence and diagnostics run--run-.zip.sha256 # matching SHA-256 package sidecar ``` +Each verified ZIP uses a versioned, non-overlapping layout. Registered source +material is stored under `evidence/raw/`, generated collector output under +`evidence/derived/`, the human summary under `reports/`, structured diagnostics +under `logs/`, the per-artifact SHA-256 catalog under `hashes/`, and the +machine-readable run manifest under `metadata/`. The manifest records both the +layout version and every section path; the package SHA-256 remains an external +sidecar because an archive cannot contain its own final digest. + ## Requirements - Python 3.11 or later. @@ -183,6 +191,10 @@ Collectors may import the public collector contracts, but not CLI, planning, runtime, packaging, configuration, or public application-control services. Even with an approved subprocess capability, workers cannot invoke Logicytics, another collector, repository/package managers, or system reboot/shutdown commands. +Generated output is registered as derived evidence by default. Collectors that +preserve source material must explicitly register it with +`evidence_kind=EvidenceKind.RAW`; its manifest record and ZIP section retain that +classification across the isolated worker boundary. Malformed core collectors block a run. Malformed unselected plugins are quarantined and listed by preflight; malformed explicitly selected plugins block diff --git a/TODO.md b/TODO.md index da1caea5..5226270e 100644 --- a/TODO.md +++ b/TODO.md @@ -496,7 +496,7 @@ Windows platform allows. - [x] Verify package contents against the manifest before marking the run complete. - [x] Use atomic writes and temporary package names so interrupted packaging cannot look like a finished result. -- [ ] Keep raw evidence, derived reports, logs, hashes, and metadata in separate +- [x] Keep raw evidence, derived reports, logs, hashes, and metadata in separate package sections. ### Improve the public API and testability diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index 29ca1ecf..c5d4f1aa 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -6,7 +6,7 @@ import shutil from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus CHROMIUM_FILES = ("History", "History-journal", "Cookies", "Cookies-journal", "Login Data", "Login Data-journal", @@ -98,7 +98,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not copied: return CollectorResult(CollectorStatus.SKIPPED, "no supported local browser profile data met the bounded backup policy") - artifacts = tuple(context.artifacts.register_file(path) for path in copied) + artifacts = tuple( + context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied + ) context.report_progress("browser_data_backup_finished", copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("browser data backup collected", artifacts) diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index ef6d1602..4c86d51c 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus KEYWORDS = ("password", "secret", "code", "login", "api", "key", "token", "auth", "credential", "private", @@ -94,7 +94,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not copied: return CollectorResult(CollectorStatus.SKIPPED, "no sensitive-named supported files met the bounded inventory policy") - artifacts = tuple(context.artifacts.register_file(path) for path in copied) + artifacts = tuple( + context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied + ) context.report_progress("sensitive_file_inventory_finished", scanned_directories=scanned_directories, copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("sensitive file inventory collected", artifacts) diff --git a/core/media/media_backup.py b/core/media/media_backup.py index f0465a4c..5b6bdced 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -6,7 +6,7 @@ from datetime import datetime, timezone from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} @@ -90,7 +90,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: if inaccessible_roots == len(source_roots): reason = "the current user's Pictures and Videos folders are inaccessible" return CollectorResult(CollectorStatus.SKIPPED, reason) - artifacts = tuple(context.artifacts.register_file(path) for path in copied) + artifacts = tuple( + context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied + ) context.report_progress("media_backup_finished", copied_files=len(artifacts), skipped_files=skipped_files, bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("current-user media backup collected", artifacts) diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index 87c80997..e1e63633 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -5,7 +5,7 @@ import subprocess from shutil import which -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus @@ -62,7 +62,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.FAILED, "HKLM registry export failed", errors=(message,)) if not output.is_file() or output.stat().st_size == 0: return CollectorResult(CollectorStatus.FAILED, "HKLM registry export produced no backup artifact") - artifact = context.artifacts.register_file(output, media_type="text/plain") + artifact = context.artifacts.register_file( + output, + media_type="text/plain", + evidence_kind=EvidenceKind.RAW, + transformations=("exported from the HKLM registry hive",), + ) context.report_progress("hklm_backup_finished", bytes_written=artifact.size_bytes) return CollectorResult.succeeded("HKLM registry backup collected", (artifact,)) diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index e024bc6a..cffe0d92 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -5,7 +5,7 @@ import zipfile from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus MAX_FILE_BYTES = 10 * 1024 * 1024 @@ -76,7 +76,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: archived_files += 1 if archived_files == 0: return CollectorResult(CollectorStatus.SKIPPED, "no SSH files met the bounded backup policy") - artifact = context.artifacts.register_file(archive, media_type="application/zip") + artifact = context.artifacts.register_file( + archive, + media_type="application/zip", + evidence_kind=EvidenceKind.RAW, + transformations=("archived from the current user's SSH directory",), + ) context.report_progress( "ssh_backup_finished", archived_files=archived_files, skipped_files=skipped_files, source_bytes=source_bytes, bytes_written=artifact.size_bytes, diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index 580b8682..014d14f6 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -6,7 +6,7 @@ import shutil from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus MAX_FILE_BYTES = 100 * 1024 * 1024 @@ -66,7 +66,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not copied: return CollectorResult(CollectorStatus.SKIPPED, "no configured Windows system-data files met the bounded backup policy") - artifacts = tuple(context.artifacts.register_file(path) for path in copied) + artifacts = tuple( + context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied + ) context.report_progress("windows_system_data_backup_finished", copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("Windows system-data backup collected", artifacts) diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index 0513826f..a1db6e7e 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -6,7 +6,7 @@ from pathlib import Path from shutil import which -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus @@ -66,7 +66,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not profiles: return CollectorResult(CollectorStatus.SKIPPED, "no saved Wi-Fi profiles with key material were exported") artifacts = tuple( - context.artifacts.register_file(Path(profile), media_type="application/xml") for profile in profiles) + context.artifacts.register_file( + Path(profile), media_type="application/xml", evidence_kind=EvidenceKind.RAW + ) + for profile in profiles + ) context.report_progress("wifi_profile_keys_finished", profile_count=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) return CollectorResult.succeeded("saved Wi-Fi profile keys collected", artifacts) diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 02936b39..26e6593e 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -8,6 +8,7 @@ CollectorMetadata, CollectorResult, CoreCollector, + EvidenceKind, PluginCollector, ResourceClass, RunRequest, @@ -40,6 +41,7 @@ def __getattr__(name: str): "CollectorResult", "CollectorSnapshot", "CoreCollector", + "EvidenceKind", "PluginCollector", "ResourceClass", "RunRequest", diff --git a/logicytics/api.py b/logicytics/api.py index 312a6e14..62f6d583 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -115,9 +115,8 @@ def _artifact(raw: object, collector_id: str) -> Artifact: transformations = values.get("transformations") if not isinstance(transformations, list): raise PlanError("run manifest artifact transformations must be an array") - values["transformations"] = tuple(transformations) try: - artifact = Artifact(**values) + artifact = Artifact.from_dict(values) except (TypeError, ValueError) as error: raise PlanError(f"run manifest contains an invalid registered artifact: {error}") from error relative = PurePosixPath(artifact.relative_path) diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index cfef16a4..83bacc4d 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -11,7 +11,7 @@ from threading import RLock from uuid import uuid4 -from logicytics.contracts import Artifact, ArtifactWriter +from logicytics.contracts import Artifact, ArtifactWriter, EvidenceKind from logicytics.errors import ArtifactError @@ -91,17 +91,24 @@ def register_file( source: Path, *, media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, transformations: tuple[str, ...] = (), ) -> Artifact: """Serialize destination allocation, quota checks, publication, and catalog updates.""" with self._registration_lock: - return self._register_file(source, media_type=media_type, transformations=transformations) + return self._register_file( + source, + media_type=media_type, + evidence_kind=evidence_kind, + transformations=transformations, + ) def _register_file( self, source: Path, *, media_type: str, + evidence_kind: EvidenceKind, transformations: tuple[str, ...], ) -> Artifact: if not isinstance(media_type, str) or not re.fullmatch( @@ -109,6 +116,8 @@ def _register_file( media_type, ): raise ArtifactError("artifact media_type must be a valid MIME type") + if not isinstance(evidence_kind, EvidenceKind): + raise ArtifactError("artifact evidence_kind must be an EvidenceKind value") if not isinstance(transformations, tuple) or any( not isinstance(step, str) or not step.strip() for step in transformations ): @@ -144,6 +153,7 @@ def _register_file( source_category=self._source_category, collected_at=datetime.now(timezone.utc).isoformat(), transformations=(*transformations, "copied into run artifact store"), + evidence_kind=evidence_kind, name=destination.name, ) self._bytes_registered += size_bytes diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 8416e236..d50165dc 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -51,6 +51,13 @@ class RunStatus(StrEnum): CANCELLED = "cancelled" +class EvidenceKind(StrEnum): + """The package section that owns a registered evidence artifact.""" + + RAW = "raw" + DERIVED = "derived" + + class Specialty(StrEnum): """The closed set of supported collector specialties.""" @@ -237,6 +244,7 @@ class Artifact: source_category: str collected_at: str transformations: tuple[str, ...] + evidence_kind: EvidenceKind name: str status: str = "registered" @@ -270,6 +278,22 @@ def __post_init__(self) -> None: not isinstance(step, str) or not step.strip() for step in self.transformations ): raise ValueError("artifact transformations must be a tuple of non-empty strings") + if not isinstance(self.evidence_kind, EvidenceKind): + raise ValueError("artifact evidence_kind must be an EvidenceKind value") + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "Artifact": + """Reconstruct a strict artifact after a JSON or worker boundary.""" + values = dict(data) + transformations = values.get("transformations") + if not isinstance(transformations, (list, tuple)): + raise ValueError("artifact transformations must be an array or tuple") + values["transformations"] = tuple(transformations) + try: + values["evidence_kind"] = EvidenceKind(values["evidence_kind"]) + except (KeyError, TypeError, ValueError) as error: + raise ValueError("artifact evidence_kind must be raw or derived") from error + return cls(**values) def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -476,6 +500,7 @@ def register_file( source: Path, *, media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, transformations: tuple[str, ...] = (), ) -> Artifact: """Register a file created within the collector workspace.""" diff --git a/logicytics/manifest.py b/logicytics/manifest.py index 7ecdef6f..eed5f410 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -127,6 +127,7 @@ class RunManifest: errors: list[dict[str, str]] = field(default_factory=list) skipped_collectors: list[str] = field(default_factory=list) package: Mapping[str, str] | None = None + package_layout_version: str = "1.0" total_artifact_bytes: int = 0 @classmethod @@ -185,7 +186,7 @@ def finalize_status(self) -> None: def artifact_list(self) -> tuple[Artifact, ...]: """Return all registered artifacts reconstructed from the manifest.""" - return tuple(Artifact(**{key: value for key, value in item.items() if key != "producer_status"}) + return tuple(Artifact.from_dict({key: value for key, value in item.items() if key != "producer_status"}) for item in self.artifact_catalog()) def artifact_catalog(self) -> tuple[dict[str, Any], ...]: @@ -195,7 +196,7 @@ def artifact_catalog(self) -> tuple[dict[str, Any], ...]: artifact_paths: set[str] = set() for record in self.collectors: for item in record.artifacts: - artifact = Artifact(**item) + artifact = Artifact.from_dict(item) if artifact.collector_id != record.id: raise ValueError(f"manifest artifact collector ownership is invalid: {artifact.relative_path}") relative = PurePosixPath(artifact.relative_path) @@ -224,6 +225,14 @@ def to_dict(self) -> dict[str, Any]: data = asdict(self) data["status"] = self.status.value data["artifact_catalog"] = self.artifact_catalog() + data["package_sections"] = { + "raw_evidence": "evidence/raw/", + "derived_reports": "evidence/derived/", + "reports": "reports/", + "logs": "logs/", + "hashes": "hashes/", + "metadata": "metadata/", + } return data diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 3dc6ab53..98481037 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json import os import re import zipfile @@ -18,6 +19,9 @@ from logicytics.runtime import RunOutcome _STREAM_BLOCK_BYTES = 1024 * 1024 +_MANIFEST_ARCHIVE_PATH = "metadata/manifest.json" +_SUMMARY_ARCHIVE_PATH = "reports/summary.txt" +_ARTIFACT_HASH_ARCHIVE_PATH = "hashes/artifacts.sha256" def _package_filename(manifest: RunManifest) -> str: @@ -96,7 +100,7 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ archive_names: set[str] = set() artifact_root = (run_directory / "artifacts").resolve() for item in manifest.artifact_catalog(): - artifact = Artifact(**{key: value for key, value in item.items() if key != "producer_status"}) + artifact = Artifact.from_dict({key: value for key, value in item.items() if key != "producer_status"}) relative = PurePosixPath(artifact.relative_path) owner = artifact.collector_id.replace(".", "_") if ( @@ -118,7 +122,7 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ raise ValueError( f"manifest artifact escapes its collector-owned store: {artifact.relative_path}" ) from error - archive_name = f"artifacts/{relative.as_posix()}" + archive_name = _artifact_archive_name(artifact) if archive_name in archive_names: raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") if source.stat().st_size != artifact.size_bytes or sha256_file(source) != artifact.sha256: @@ -128,21 +132,46 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ return sources +def _artifact_archive_name(artifact: Artifact) -> str: + """Map a typed artifact to its canonical evidence package section.""" + return f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" + + +def _artifact_checksum_catalog(manifest: RunManifest) -> str: + """Return a deterministic SHA-256 catalog for every packaged evidence member.""" + entries = sorted( + (_artifact_archive_name(artifact), artifact.sha256) + for artifact in manifest.artifact_list() + ) + return "".join(f"{digest} {archive_name}\n" for archive_name, digest in entries) + + +def _write_text_atomic(path: Path, contents: str, *, encoding: str) -> None: + """Publish a run-owned report without exposing a partially written file.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.tmp") + temporary.write_text(contents, encoding=encoding, newline="\n") + os.replace(temporary, path) + + def _log_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[Path, str]]: """Return only the engine log and manifest-owned collector event channels.""" sources: list[tuple[Path, str]] = [] root = run_directory.resolve() - candidates = [root / "logs" / "engine.jsonl"] + candidates = [(root / "logs" / "engine.jsonl", "logs/engine.jsonl")] candidates.extend( - root / "collectors" / record.id.replace(".", "_") / "events.jsonl" + ( + root / "collectors" / record.id.replace(".", "_") / "events.jsonl", + f"logs/collectors/{record.id.replace('.', '_')}/events.jsonl", + ) for record in manifest.collectors ) - for path in candidates: + for path, archive_name in candidates: if not path.exists() and not path.is_symlink(): continue if not path.is_file() or path.resolve(strict=True) != path: raise ValueError(f"diagnostic log escapes its run-owned event channel: {path.name}") - sources.append((path, path.relative_to(root).as_posix())) + sources.append((path, archive_name)) if manifest.request.get("performance_check") is True: performance_path = root / "logs" / "performance.json" if not performance_path.is_file(): @@ -176,8 +205,18 @@ def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: s raise ValueError("package contents do not match its manifest-led input set") if archive.testzip() is not None: raise ValueError("package integrity verification failed") + try: + packaged_manifest = json.loads(archive.read(_MANIFEST_ARCHIVE_PATH)) + checksum_catalog = archive.read(_ARTIFACT_HASH_ARCHIVE_PATH).decode("ascii") + except (KeyError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError("package metadata or artifact checksum catalog is invalid") from error + expected_manifest = json.loads(json.dumps(manifest.to_dict())) + if packaged_manifest != expected_manifest: + raise ValueError("packaged metadata does not match the finalized run manifest") + if checksum_catalog != _artifact_checksum_catalog(manifest): + raise ValueError("packaged artifact checksum catalog does not match the manifest") for artifact in manifest.artifact_list(): - archive_name = f"artifacts/{artifact.relative_path}" + archive_name = _artifact_archive_name(artifact) member = archive.getinfo(archive_name) with archive.open(member) as stream: digest = _sha256_stream(stream) @@ -192,21 +231,24 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: package_path = package_directory / _package_filename(manifest) hash_path = package_path.with_suffix(".zip.sha256") - summary_path = run_directory / "summary.txt" + summary_path = run_directory / "reports" / "summary.txt" + artifact_hash_path = run_directory / "hashes" / "artifacts.sha256" artifact_sources = _artifact_sources(run_directory, manifest) log_sources = _log_sources(run_directory, manifest) manifest.package = {"path": str(package_path)} write_manifest(manifest_path, manifest) - summary_path.write_text(_summary(manifest), encoding="utf-8") - expected_names = {"manifest.json", "summary.txt"} + _write_text_atomic(summary_path, _summary(manifest), encoding="utf-8") + _write_text_atomic(artifact_hash_path, _artifact_checksum_catalog(manifest), encoding="ascii") + expected_names = {_MANIFEST_ARCHIVE_PATH, _SUMMARY_ARCHIVE_PATH, _ARTIFACT_HASH_ARCHIVE_PATH} expected_names.update(archive_name for _, archive_name in artifact_sources) expected_names.update(archive_name for _, archive_name in log_sources) temporary_package = package_path.with_suffix(".zip.tmp") temporary_hash = hash_path.with_suffix(".sha256.tmp") try: with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: - _stream_archive_member(archive, manifest_path, "manifest.json") - _stream_archive_member(archive, summary_path, "summary.txt") + _stream_archive_member(archive, manifest_path, _MANIFEST_ARCHIVE_PATH) + _stream_archive_member(archive, summary_path, _SUMMARY_ARCHIVE_PATH) + _stream_archive_member(archive, artifact_hash_path, _ARTIFACT_HASH_ARCHIVE_PATH) for source, archive_name in (*artifact_sources, *log_sources): _stream_archive_member(archive, source, archive_name) _verify_archive(temporary_package, manifest, expected_names) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 2f3d3d1e..98afbbca 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -264,19 +264,7 @@ def _load_collector(path: Path, expected_class: str): def _artifact_from_dict(data: dict[str, object]) -> Artifact: - return Artifact( - id=data["id"], - relative_path=data["relative_path"], - sha256=data["sha256"], - size_bytes=data["size_bytes"], - media_type=data["media_type"], - collector_id=data["collector_id"], - source_category=data["source_category"], - collected_at=data["collected_at"], - transformations=data["transformations"], - name=data["name"], - status=data["status"], - ) + return Artifact.from_dict(data) def _result_from_dict(data: dict[str, object]) -> CollectorResult: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index f024ab7f..023924c8 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -34,6 +34,7 @@ CollectorMetadata, CollectorResult, CollectorStatus, + EvidenceKind, ResourceClass, RunRequest, RunStatus, @@ -223,7 +224,7 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> outcome = run_collection(root, request, configuration=configuration) snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) - self.assertEqual(RunStatus.SUCCEEDED, snapshot.status) + self.assertEqual(RunStatus.SUCCEEDED, snapshot.status, outcome.manifest.package) self.assertEqual(outcome.run_directory, snapshot.run_directory) self.assertEqual(outcome.manifest_path, snapshot.manifest_path) self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) @@ -444,6 +445,7 @@ def test_artifact_contract_rejects_malformed_catalog_metadata(self) -> None: "source_category": "system", "collected_at": "2026-01-01T00:00:00+00:00", "transformations": ("normalized",), + "evidence_kind": EvidenceKind.DERIVED, "name": "report.json", "status": "registered", } @@ -457,6 +459,7 @@ def test_artifact_contract_rejects_malformed_catalog_metadata(self) -> None: ({"source_category": "System"}, "source_category"), ({"collected_at": "2026-01-01T00:00:00"}, "timezone"), ({"transformations": ["normalized"]}, "transformations"), + ({"evidence_kind": "derived"}, "evidence_kind"), ({"name": "../report.json"}, "name"), ({"status": "failed"}, "status"), ) @@ -843,7 +846,7 @@ def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evi self.assertEqual("keep legacy evidence", legacy.read_text(encoding="utf-8")) self.assertEqual(original, config_path.read_text(encoding="utf-8")) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = json.loads(archive.read("manifest.json")) + packaged = json.loads(archive.read("metadata/manifest.json")) self.assertEqual(4, packaged["configuration"]["schema_version"]) self.assertEqual(3, packaged["configuration"]["migrated_from_schema"]) @@ -1507,11 +1510,18 @@ def test_artifact_registration_records_validated_provenance(self) -> None: writer.register_file(source, transformations=["normalized"]) # type: ignore[arg-type] with self.assertRaisesRegex(ArtifactError, "media_type"): writer.register_file(source, media_type="not a mime type") + with self.assertRaisesRegex(ArtifactError, "evidence_kind"): + writer.register_file(source, evidence_kind="raw") # type: ignore[arg-type] self.assertEqual((), writer.artifacts) - artifact = writer.register_file(source, transformations=("normalized",)) + artifact = writer.register_file( + source, + evidence_kind=EvidenceKind.RAW, + transformations=("normalized",), + ) self.assertEqual("report.json", artifact.name) self.assertEqual("registered", artifact.status) self.assertEqual("evidence_graph", artifact.source_category) + self.assertEqual(EvidenceKind.RAW, artifact.evidence_kind) datetime.fromisoformat(artifact.collected_at) self.assertEqual( ("normalized", "copied into run artifact store"), @@ -1585,18 +1595,21 @@ def capture_manifest_state(path, manifest): self.assertEqual(package_digest, outcome.manifest.package["sha256"]) with zipfile.ZipFile(package_path) as archive: self.assertIsNone(archive.testzip()) - self.assertIn("manifest.json", archive.namelist()) - self.assertIn("summary.txt", archive.namelist()) + self.assertIn("metadata/manifest.json", archive.namelist()) + self.assertIn("reports/summary.txt", archive.namelist()) + self.assertIn("hashes/artifacts.sha256", archive.namelist()) self.assertNotIn("logs/performance.json", archive.namelist()) - self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("artifacts/")])) + self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("evidence/")])) artifact = outcome.manifest.artifact_list()[0] - archived_bytes = archive.read(f"artifacts/{artifact.relative_path}") + archived_bytes = archive.read( + f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" + ) self.assertEqual(artifact.size_bytes, len(archived_bytes)) self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) self.assertEqual("system", artifact.source_category) datetime.fromisoformat(artifact.collected_at) self.assertEqual(("copied into run artifact store",), artifact.transformations) - packaged_manifest = json.loads(archive.read("manifest.json")) + packaged_manifest = json.loads(archive.read("metadata/manifest.json")) self.assertEqual(["core.system.system_info"], packaged_manifest["resolved_plan"]) self.assertEqual(plan.fingerprint, packaged_manifest["plan_fingerprint"]) self.assertFalse(packaged_manifest["cancellation_requested"]) @@ -1614,7 +1627,7 @@ def capture_manifest_state(path, manifest): self.assertEqual("succeeded", catalog_artifact["producer_status"]) self.assertEqual("core.system.system_info", catalog_artifact["collector_id"]) record = outcome.manifest.collectors[0] - summary = archive.read("summary.txt").decode("utf-8") + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertIn(f"Status: {record.status}", summary) self.assertIn("Cancellation requested: false", summary) self.assertIn("Resolved collectors: 1", summary) @@ -1626,6 +1639,69 @@ def capture_manifest_state(path, manifest): self.assertNotEqual(outcome.manifest.run_id, repeated.manifest.run_id) self.assertNotEqual(outcome.run_directory, repeated.run_directory) + def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) -> None: + """The versioned package layout is manifest-led and has no ambiguous root entries.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + source = _COLLECTOR.replace( + "CollectorResult, CoreCollector, Specialty", + "CollectorResult, CoreCollector, EvidenceKind, Specialty", + ).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raw = context.workspace / "source.bin"\n' + ' raw.write_bytes(b"raw evidence")\n' + ' raw_artifact = context.artifacts.register_file(\n' + ' raw, evidence_kind=EvidenceKind.RAW,\n' + ' )\n' + ' return CollectorResult.succeeded(\n' + ' "typed artifacts created", (artifact, raw_artifact),\n' + ' )', + ) + collector_path.write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + outcome = RunSupervisor(root, default_config(root)).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + ) + self.assertEqual("succeeded", outcome.manifest.status.value, outcome.manifest.package) + + package_path = Path(outcome.manifest.package["path"]) + with zipfile.ZipFile(package_path) as archive: + names = archive.namelist() + self.assertEqual( + {"evidence", "hashes", "logs", "metadata", "reports"}, + {name.split("/", 1)[0] for name in names}, + ) + self.assertNotIn("manifest.json", names) + self.assertNotIn("summary.txt", names) + self.assertFalse(any(name.startswith("artifacts/") for name in names)) + artifacts = outcome.manifest.artifact_list() + expected_catalog = "".join( + f"{artifact.sha256} evidence/{artifact.evidence_kind.value}/{artifact.relative_path}\n" + for artifact in sorted( + artifacts, + key=lambda item: f"evidence/{item.evidence_kind.value}/{item.relative_path}", + ) + ) + self.assertEqual( + expected_catalog, + archive.read("hashes/artifacts.sha256").decode("ascii"), + ) + for artifact in artifacts: + archive_name = f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" + self.assertEqual(artifact.sha256, hashlib.sha256(archive.read(archive_name)).hexdigest()) + packaged = json.loads(archive.read("metadata/manifest.json")) + self.assertEqual("1.0", packaged["package_layout_version"]) + self.assertEqual("evidence/raw/", packaged["package_sections"]["raw_evidence"]) + self.assertEqual("evidence/derived/", packaged["package_sections"]["derived_reports"]) + self.assertEqual( + {"raw", "derived"}, + {item["evidence_kind"] for item in packaged["artifact_catalog"]}, + ) + def test_configured_output_root_colocates_packages_without_touching_legacy_evidence(self) -> None: """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" with tempfile.TemporaryDirectory() as temporary: @@ -1690,11 +1766,11 @@ def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> Non package_path = Path(outcome.manifest.package["path"]) self.assertTrue(package_path.is_file()) with zipfile.ZipFile(package_path) as archive: - packaged = json.loads(archive.read("manifest.json")) - summary = archive.read("summary.txt").decode("utf-8") + packaged = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual( ["ok"], - archive.read("artifacts/core_system_system_info/system.txt").decode("utf-8").splitlines(), + archive.read("evidence/derived/core_system_system_info/system.txt").decode("utf-8").splitlines(), ) self.assertEqual("partial", packaged["status"]) self.assertEqual("partial", packaged["collectors"][0]["status"]) @@ -1751,12 +1827,12 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( self.assertEqual(original_manifest, original.manifest_path.read_bytes()) self.assertEqual(original_package, original_package_path.read_bytes()) with zipfile.ZipFile(Path(rerun.manifest.package["path"])) as archive: - packaged_manifest = json.loads(archive.read("manifest.json")) - summary = archive.read("summary.txt").decode("utf-8") - artifact_names = [name for name in archive.namelist() if name.startswith("artifacts/")] + packaged_manifest = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") + artifact_names = [name for name in archive.namelist() if name.startswith("evidence/")] self.assertEqual(original.manifest.run_id, packaged_manifest["parent_run_id"]) self.assertEqual("rerun", packaged_manifest["action"]) - self.assertEqual(["artifacts/core_system_z_selected/system.txt"], artifact_names) + self.assertEqual(["evidence/derived/core_system_z_selected/system.txt"], artifact_names) self.assertIn("Action: rerun", summary) self.assertIn(f"Parent run: {original.manifest.run_id}", summary) @@ -1806,12 +1882,12 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r artifact = outcome.manifest.artifact_list()[0] self.assertEqual( "password=evidence-password token=evidence-token", - archive.read(f"artifacts/{artifact.relative_path}").decode("utf-8"), + archive.read(f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}").decode("utf-8"), ) diagnostics = "\n".join( archive.read(name).decode("utf-8") for name in archive.namelist() - if not name.startswith("artifacts/") + if not name.startswith("evidence/") ) self.assertNotIn("evidence-password", diagnostics) self.assertNotIn("evidence-token", diagnostics) @@ -1844,12 +1920,12 @@ def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) - self.assertNotIn("crash-token", failure["platform_error"]) self.assertIn("[REDACTED]", failure["platform_error"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] self.assertEqual(failure, packaged_record["failure"]) diagnostics = "\n".join( archive.read(name).decode("utf-8") for name in archive.namelist() - if not name.startswith("artifacts/") + if not name.startswith("evidence/") ) self.assertNotIn("crash-password", diagnostics) self.assertNotIn("crash-token", diagnostics) @@ -1938,9 +2014,9 @@ def capture_manifest(path: Path, manifest: object) -> None: ) ) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] report = json.loads(archive.read("logs/performance.json"))["collectors"][0] - summary = archive.read("summary.txt").decode("utf-8") + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(record.progress, packaged_record["progress"]) self.assertEqual(record.progress, report["progress"]) self.assertEqual(record.peak_memory_bytes, report["peak_memory_bytes"]) @@ -1966,7 +2042,7 @@ def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self package_path, _ = package_run(outcome) with zipfile.ZipFile(package_path) as archive: - self.assertNotIn("artifacts/unregistered.txt", archive.namelist()) + self.assertNotIn("evidence/derived/unregistered.txt", archive.namelist()) original_write = packaging._stream_archive_member @@ -1975,7 +2051,7 @@ def tampering_write( filename: Path, arcname: str, ) -> None: - if arcname.startswith("artifacts/"): + if arcname.startswith("evidence/"): archive.writestr(arcname, b"tampered artifact bytes") return original_write(archive, filename, arcname) @@ -2005,7 +2081,7 @@ def test_package_excludes_unregistered_collector_and_engine_jsonl_files(self) -> package_path, _ = package_run(outcome) with zipfile.ZipFile(package_path) as archive: self.assertIn("logs/engine.jsonl", archive.namelist()) - self.assertIn("collectors/core_system_system_info/events.jsonl", archive.namelist()) + self.assertIn("logs/collectors/core_system_system_info/events.jsonl", archive.namelist()) self.assertNotIn("logs/unregistered.jsonl", archive.namelist()) self.assertNotIn("collectors/core_system_system_info/unregistered.jsonl", archive.namelist()) @@ -2107,8 +2183,11 @@ def test_package_preserves_nested_registered_artifact_directories(self) -> None: expected = "core_system_system_info/nested/reports/system.txt" self.assertEqual(expected, outcome.manifest.artifact_list()[0].relative_path) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn(f"artifacts/{expected}", archive.namelist()) - self.assertEqual(expected, json.loads(archive.read("manifest.json"))["artifact_catalog"][0]["relative_path"]) + self.assertIn(f"evidence/derived/{expected}", archive.namelist()) + self.assertEqual( + expected, + json.loads(archive.read("metadata/manifest.json"))["artifact_catalog"][0]["relative_path"], + ) def test_package_rejects_forged_names_status_and_duplicate_catalog_identifiers(self) -> None: """A package is refused whenever its finalized evidence catalog is inconsistent.""" @@ -2569,8 +2648,8 @@ def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: {error["collector_id"] for error in outcome.manifest.errors}, ) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = json.loads(archive.read("manifest.json")) - summary = archive.read("summary.txt").decode("utf-8") + packaged = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(["core.system.b_dependent"], packaged["skipped_collectors"]) self.assertIn("Skipped collectors: 1", summary) @@ -2602,7 +2681,7 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N package_path = Path(outcome.manifest.package["path"]) self.assertTrue(package_path.is_file()) with zipfile.ZipFile(package_path) as archive: - summary = archive.read("summary.txt").decode("utf-8") + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertIn("Status: failed", summary) self.assertIn("Reasons:", summary) self.assertIn("RuntimeError", summary) @@ -2653,8 +2732,8 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N self.assertEqual("failed", record.retry_history[0]["termination_reason"]) self.assertEqual("completed", record.termination_reason) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_record = json.loads(archive.read("manifest.json"))["collectors"][0] - summary = archive.read("summary.txt").decode("utf-8") + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(2, packaged_record["attempt_count"]) self.assertEqual(1, len(packaged_record["retry_history"])) self.assertIsNone(packaged_record["failure"]) @@ -2719,9 +2798,9 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non self.assertIn("failure after evidence registration", record.failure["platform_error"]) self.assertFalse(record.failure["retry_safe"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn("artifacts/core_system_system_info/system.txt", archive.namelist()) - packaged = json.loads(archive.read("manifest.json"))["collectors"][0] - summary = archive.read("summary.txt").decode("utf-8") + self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) + packaged = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(record.failure, packaged["failure"]) self.assertIn("Failed operation: collect", summary) self.assertIn("Retry safe: false", summary) @@ -2770,9 +2849,12 @@ def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolat self.assertEqual("completed", succeeded.termination_reason) self.assertNotEqual(failed.worker_pid, succeeded.worker_pid) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = {item["id"]: item for item in json.loads(archive.read("manifest.json"))["collectors"]} - summary = archive.read("summary.txt").decode("utf-8") - self.assertIn("artifacts/core_system_a_failed/system.txt", archive.namelist()) + packaged = { + item["id"]: item + for item in json.loads(archive.read("metadata/manifest.json"))["collectors"] + } + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertIn("evidence/derived/core_system_a_failed/system.txt", archive.namelist()) self.assertEqual("failed", packaged["core.system.a_failed"]["status"]) self.assertEqual("process", packaged["core.system.a_failed"]["isolation_mode"]) self.assertEqual(failed.worker_pid, packaged["core.system.a_failed"]["worker_pid"]) @@ -2839,7 +2921,7 @@ def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> No self.assertIn("cleanup", record.failure["remediation"]) self.assertFalse(record.failure["retry_safe"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn("artifacts/core_system_system_info/system.txt", archive.namelist()) + self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) def test_collector_timeout_preserves_independent_worker_results(self) -> None: """A timed-out worker is terminated without cancelling unrelated collection.""" @@ -3299,8 +3381,8 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: package_path = Path(outcome.manifest.package["path"]) self.assertTrue(package_path.is_file()) with zipfile.ZipFile(package_path) as archive: - summary = archive.read("summary.txt").decode("utf-8") - packaged_manifest = json.loads(archive.read("manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") + packaged_manifest = json.loads(archive.read("metadata/manifest.json")) self.assertTrue(packaged_manifest["cancellation_requested"]) self.assertIn("Status: cancelled", summary) self.assertIn("Cancellation requested: true", summary) From e4fffa287fcfa5546537c26347974369a8adf908 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 17:10:51 +0300 Subject: [PATCH 162/316] refactor: keep package outputs within each run --- README.md | 17 ++++++++++++----- TODO.md | 2 +- logicytics/packaging.py | 8 +++++--- tests/test_core_functionality.py | 8 ++++---- 4 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5a0545b4..6d0a1e3c 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,16 @@ logicytics/ # engine, contracts, planning, runtime, and packaging tests/ # core integration tests output/ # ignored generated evidence and diagnostics data/ - run-/ # isolated run artifacts, manifest, and logs/ - run--run-.zip # action/timestamp-named evidence package - run--run-.zip.sha256 # matching SHA-256 package sidecar + run-/ # one self-contained run output tree + artifacts/ # collector-owned registered evidence store + collectors/ # private collector workspaces and event channels + logs/ # engine and performance diagnostics + reports/ # human-readable run summary + packages/ + run--run-.zip # verified evidence package + hashes/ + artifacts.sha256 # packaged-artifact checksum catalog + run--run-.zip.sha256 # matching package SHA-256 sidecar ``` Each verified ZIP uses a versioned, non-overlapping layout. Registered source @@ -101,8 +108,8 @@ python -m logicytics run --profile standard --acknowledge-authorization collector order without collecting data. `run` requires an explicit authorization acknowledgement and writes each run below `output/data/run-/`. Its verified ZIP package and SHA-256 sidecar use the action, UTC request timestamp, and run -ID, and are written alongside that run directory under the same `output/data/` -root; collector workspaces and `logs/` remain run-scoped. Explicit reruns use a +ID, and are written into that run's `packages/` and `hashes/` directories; +collector workspaces and `logs/` remain run-scoped. Explicit reruns use a `rerun--run-.zip` package identity. Collectors that request additional access must be explicitly approved, for diff --git a/TODO.md b/TODO.md index 5226270e..0a5211bc 100644 --- a/TODO.md +++ b/TODO.md @@ -868,7 +868,7 @@ appropriate default/deep/non-Python mode. retry duration. - [x] Version the configuration schema and provide migration/default handling. - [ ] Define stable output names and formats for every collector. -- [ ] Keep logs, hashes, packages, graphs, CSVs, HTML, text, and copied evidence +- [x] Keep logs, hashes, packages, graphs, CSVs, HTML, text, and copied evidence discoverable under one run-specific output tree. - [x] Include collection metadata: version, action, start/end times, host, user, privilege state, enabled collectors, skipped collectors, and failures. diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 98481037..5d2d7a9f 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -226,13 +226,15 @@ def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: s def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: Path) -> tuple[Path, Path]: """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" - package_directory = run_directory.parent + package_directory = run_directory / "packages" + hash_directory = run_directory / "hashes" package_directory.mkdir(parents=True, exist_ok=True) + hash_directory.mkdir(parents=True, exist_ok=True) package_path = package_directory / _package_filename(manifest) - hash_path = package_path.with_suffix(".zip.sha256") + hash_path = hash_directory / f"{package_path.name}.sha256" summary_path = run_directory / "reports" / "summary.txt" - artifact_hash_path = run_directory / "hashes" / "artifacts.sha256" + artifact_hash_path = hash_directory / "artifacts.sha256" artifact_sources = _artifact_sources(run_directory, manifest) log_sources = _log_sources(run_directory, manifest) manifest.package = {"path": str(package_path)} diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 023924c8..ce1ea3eb 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1576,8 +1576,8 @@ def capture_manifest_state(path, manifest): self.assertIsNotNone(outcome.manifest.package) self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) self.assertTrue(Path(outcome.manifest.package["sha256_path"]).is_file()) - self.assertEqual(outcome.run_directory.parent, Path(outcome.manifest.package["path"]).parent) - self.assertEqual(outcome.run_directory.parent, Path(outcome.manifest.package["sha256_path"]).parent) + self.assertEqual(outcome.run_directory / "packages", Path(outcome.manifest.package["path"]).parent) + self.assertEqual(outcome.run_directory / "hashes", Path(outcome.manifest.package["sha256_path"]).parent) self.assertTrue((outcome.run_directory / "logs" / "engine.jsonl").is_file()) self.assertFalse((root / "ACCESS").exists()) self.assertFalse((root / "output" / "RUNS").exists()) @@ -1725,8 +1725,8 @@ def test_configured_output_root_colocates_packages_without_touching_legacy_evide hash_path = Path(outcome.manifest.package["sha256_path"]) self.assertEqual(expected_root, outcome.run_directory.parent) - self.assertEqual(expected_root, package_path.parent) - self.assertEqual(expected_root, hash_path.parent) + self.assertEqual(outcome.run_directory / "packages", package_path.parent) + self.assertEqual(outcome.run_directory / "hashes", hash_path.parent) self.assertEqual("preserve existing evidence", legacy_evidence.read_text(encoding="utf-8")) self.assertFalse((root / "custom" / "PACKAGES").exists()) self.assertEqual( From 2322e4e02a006335ccbb6fcb9fce23f83953af94 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 17:20:34 +0300 Subject: [PATCH 163/316] fix: honor cancellation across long collector phases --- README.md | 2 + TODO.md | 2 +- core/browser/browser_data_backup.py | 33 ++- core/event_log/application_events.py | 5 + core/event_log/security_events.py | 5 + core/event_log/system_events.py | 5 + core/filesystem/sensitive_file_inventory.py | 20 +- core/media/media_backup.py | 20 +- core/packet/packet_capture.py | 5 + core/process/memory_map.py | 7 + core/ssh/ssh_backup.py | 13 +- core/system/windows_system_data_backup.py | 22 +- tests/test_collector_cancellation.py | 225 ++++++++++++++++++++ 13 files changed, 340 insertions(+), 24 deletions(-) create mode 100644 tests/test_collector_cancellation.py diff --git a/README.md b/README.md index 6d0a1e3c..2c3bf141 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ rebuilt incrementally. - One process and private workspace per collector. - Explicit capability approval and collection authorization. - Structured JSONL engine and collector logs. +- Cooperative cancellation across long walks, captures, queries, memory enumeration, + and source-copy loops, with unpublished sensitive bytes removed. - Artifact registration with workspace boundaries, output limits, SHA-256 hashes, a run manifest, ZIP package, and package hash. - Shipped collectors: `core.system.system_info` and the capability-gated diff --git a/TODO.md b/TODO.md index 0a5211bc..207f85e1 100644 --- a/TODO.md +++ b/TODO.md @@ -424,7 +424,7 @@ Windows platform allows. default profile. - [x] Add per-collector timeouts, retry policies, maximum output size, maximum file size, maximum file count, and maximum total run size. -- [ ] Add cancellation checks inside long filesystem walks, packet capture, +- [x] Add cancellation checks inside long filesystem walks, packet capture, memory collection, event-log queries, and copy loops. - [x] Prevent path traversal and symlink/junction escapes from leaving the run workspace or copying unintended locations. diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index c5d4f1aa..7e009179 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -81,6 +81,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: destination, copied_bytes = _copy_file(profile / filename, context.workspace / "browser_data" / browser / profile.name / filename, copied_bytes) + if context.is_cancelled: + if destination is not None: + destination.unlink(missing_ok=True) + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") if destination is not None: copied.append(destination) firefox_profiles = roaming / "Mozilla" / "Firefox" / "Profiles" @@ -90,20 +96,35 @@ def collect(self, context: CollectorContext) -> CollectorResult: profiles = [] for profile in profiles: for filename in FIREFOX_FILES: + if context.is_cancelled: + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") destination, copied_bytes = _copy_file(profile / filename, context.workspace / "browser_data" / "firefox" / profile.name / filename, copied_bytes) + if context.is_cancelled: + if destination is not None: + destination.unlink(missing_ok=True) + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") if destination is not None: copied.append(destination) if not copied: return CollectorResult(CollectorStatus.SKIPPED, "no supported local browser profile data met the bounded backup policy") - artifacts = tuple( - context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied - ) - context.report_progress("browser_data_backup_finished", copied_files=len(artifacts), - bytes_written=sum(item.size_bytes for item in artifacts)) - return CollectorResult.succeeded("browser data backup collected", artifacts) + artifacts = [] + for path in copied: + if context.is_cancelled: + for unpublished in copied[len(artifacts):]: + unpublished.unlink(missing_ok=True) + return CollectorResult.cancelled("cancelled during browser-data registration", tuple(artifacts)) + artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) + artifact_tuple = tuple(artifacts) + context.report_progress("browser_data_backup_finished", copied_files=len(artifact_tuple), + bytes_written=sum(item.size_bytes for item in artifact_tuple)) + return CollectorResult.succeeded("browser data backup collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 37d74ba7..8395812e 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -63,6 +63,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: text=True, timeout=75, ) + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Application event-log query") if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): @@ -72,6 +74,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.FAILED, "Application event-log query failed", errors=(detail,)) output = context.workspace / "application_events.csv" output.write_text(completed.stdout, encoding="utf-8") + if context.is_cancelled: + output.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Application event-log export") artifact = context.artifacts.register_file(output, media_type="text/csv") event_count = max(0, len(completed.stdout.splitlines()) - 1) context.report_progress("application_events_finished", event_count=event_count, diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index 2c5fa6ff..392491ae 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -55,6 +55,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: ) completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, check=False, text=True, timeout=75) + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Security event-log query") if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): @@ -63,6 +65,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.FAILED, "Security event-log query failed", errors=(detail,)) output = context.workspace / "security_events.csv" output.write_text(completed.stdout, encoding="utf-8") + if context.is_cancelled: + output.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Security event-log export") artifact = context.artifacts.register_file(output, media_type="text/csv") event_count = max(0, len(completed.stdout.splitlines()) - 1) context.report_progress("security_events_finished", event_count=event_count, bytes_written=artifact.size_bytes) diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py index 509e1273..0b306176 100644 --- a/core/event_log/system_events.py +++ b/core/event_log/system_events.py @@ -70,6 +70,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: text=True, timeout=75, ) + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during System event-log query") if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): @@ -81,6 +83,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.FAILED, "System event-log query failed", errors=(detail,)) output = context.workspace / "system_events.csv" output.write_text(completed.stdout, encoding="utf-8") + if context.is_cancelled: + output.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during System event-log export") artifact = context.artifacts.register_file(output, media_type="text/csv") event_count = max(0, len(completed.stdout.splitlines()) - 1) context.report_progress("system_events_finished", event_count=event_count, bytes_written=artifact.size_bytes) diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index 4c86d51c..ca602957 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -91,15 +91,25 @@ def collect(self, context: CollectorContext) -> CollectorResult: destination_root / source.drive.replace(":", "") / source.relative_to( root)), matches) if item is not None] + if context.is_cancelled: + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file copy") if not copied: return CollectorResult(CollectorStatus.SKIPPED, "no sensitive-named supported files met the bounded inventory policy") - artifacts = tuple( - context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied - ) + artifacts = [] + for path in copied: + if context.is_cancelled: + for unpublished in copied[len(artifacts):]: + unpublished.unlink(missing_ok=True) + return CollectorResult.cancelled("cancelled during sensitive-file registration", tuple(artifacts)) + artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) + artifact_tuple = tuple(artifacts) context.report_progress("sensitive_file_inventory_finished", scanned_directories=scanned_directories, - copied_files=len(artifacts), bytes_written=sum(item.size_bytes for item in artifacts)) - return CollectorResult.succeeded("sensitive file inventory collected", artifacts) + copied_files=len(artifact_tuple), + bytes_written=sum(item.size_bytes for item in artifact_tuple)) + return CollectorResult.succeeded("sensitive file inventory collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/core/media/media_backup.py b/core/media/media_backup.py index 5b6bdced..b1b81194 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -81,6 +81,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: suffix += 1 destination.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(candidate, destination) + if context.is_cancelled: + destination.unlink(missing_ok=True) + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during media backup") copied.append(destination) copied_bytes += size except OSError: @@ -90,12 +95,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: if inaccessible_roots == len(source_roots): reason = "the current user's Pictures and Videos folders are inaccessible" return CollectorResult(CollectorStatus.SKIPPED, reason) - artifacts = tuple( - context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied - ) + artifacts = [] + for path in copied: + if context.is_cancelled: + for unpublished in copied[len(artifacts):]: + unpublished.unlink(missing_ok=True) + return CollectorResult.cancelled("cancelled during media registration", tuple(artifacts)) + artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) + artifact_tuple = tuple(artifacts) context.report_progress("media_backup_finished", copied_files=len(artifacts), skipped_files=skipped_files, - bytes_written=sum(item.size_bytes for item in artifacts)) - return CollectorResult.succeeded("current-user media backup collected", artifacts) + bytes_written=sum(item.size_bytes for item in artifact_tuple)) + return CollectorResult.succeeded("current-user media backup collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index a2f55df3..6b944f53 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -118,12 +118,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: except OSError: pass capture.close() + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before packet-capture serialization") output = context.workspace / "packet_capture.csv" with output.open("w", newline="", encoding="utf-8") as stream: writer = csv.DictWriter(stream, fieldnames=("source_ip", "destination_ip", "protocol", "source_port", "destination_port", "packet_bytes")) writer.writeheader() writer.writerows(observations) + if context.is_cancelled: + output.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during packet-capture serialization") artifact = context.artifacts.register_file(output, media_type="text/csv") context.report_progress("packet_capture_finished", observation_count=len(observations), bytes_written=artifact.size_bytes) diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 2574cdd6..b1db55ac 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -105,6 +105,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: address = next_address truncated = False while True: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during memory-map serialization") serialized = json.dumps({"region_count": len(regions), "truncated": truncated, "regions": regions}, indent=2) + "\n" if len(serialized.encode("utf-8")) <= output_limit or not regions: @@ -114,9 +116,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if shutil.disk_usage(context.workspace).free < len(serialized.encode("utf-8")) + safety_margin: return CollectorResult(CollectorStatus.SKIPPED, "insufficient free disk space after configured memory-map safety margin") + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory-map publication") output_directory.mkdir(parents=True, exist_ok=True) output = output_directory / "memory_map.json" output.write_text(serialized, encoding="utf-8") + if context.is_cancelled: + output.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during memory-map publication") artifact = context.artifacts.register_file(output, media_type="application/json") context.report_progress("memory_map_finished", region_count=len(regions), truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index cffe0d92..3a509608 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -50,6 +50,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: source_bytes = 0 archived_files = 0 skipped_files = 0 + cancelled = False with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output: try: candidates = sorted(ssh_directory.rglob("*")) @@ -58,7 +59,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: errors=(str(error),)) for candidate in candidates: if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during SSH backup") + cancelled = True + break try: is_file = candidate.is_file() is_symlink = candidate.is_symlink() @@ -72,10 +74,19 @@ def collect(self, context: CollectorContext) -> CollectorResult: skipped_files += 1 continue output.write(candidate, arcname=candidate.relative_to(ssh_directory).as_posix()) + if context.is_cancelled: + cancelled = True + break source_bytes += size archived_files += 1 + if cancelled: + archive.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during SSH backup") if archived_files == 0: return CollectorResult(CollectorStatus.SKIPPED, "no SSH files met the bounded backup policy") + if context.is_cancelled: + archive.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before SSH-backup registration") artifact = context.artifacts.register_file( archive, media_type="application/zip", diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index 014d14f6..02529a5a 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -62,16 +62,26 @@ def collect(self, context: CollectorContext) -> CollectorResult: shutil.copy2(source, destination) except OSError: continue + if context.is_cancelled: + destination.unlink(missing_ok=True) + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Windows system-data backup") copied.append(destination) if not copied: return CollectorResult(CollectorStatus.SKIPPED, "no configured Windows system-data files met the bounded backup policy") - artifacts = tuple( - context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW) for path in copied - ) - context.report_progress("windows_system_data_backup_finished", copied_files=len(artifacts), - bytes_written=sum(item.size_bytes for item in artifacts)) - return CollectorResult.succeeded("Windows system-data backup collected", artifacts) + artifacts = [] + for path in copied: + if context.is_cancelled: + for unpublished in copied[len(artifacts):]: + unpublished.unlink(missing_ok=True) + return CollectorResult.cancelled("cancelled during system-data registration", tuple(artifacts)) + artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) + artifact_tuple = tuple(artifacts) + context.report_progress("windows_system_data_backup_finished", copied_files=len(artifact_tuple), + bytes_written=sum(item.size_bytes for item in artifact_tuple)) + return CollectorResult.succeeded("Windows system-data backup collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: """Leave copied evidence removal to the isolated workspace lifecycle.""" diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py new file mode 100644 index 00000000..2966790d --- /dev/null +++ b/tests/test_collector_cancellation.py @@ -0,0 +1,225 @@ +"""Direct cooperative-cancellation tests for long-running collector phases.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from core.browser import browser_data_backup +from core.event_log import application_events, security_events, system_events +from core.filesystem import sensitive_file_inventory +from core.media import media_backup +from core.packet import packet_capture +from core.process import memory_map +from core.ssh import ssh_backup +from core.system import windows_system_data_backup +from logicytics.contracts import CollectorStatus + + +class _RejectingArtifacts: + def register_file(self, *_args, **_kwargs): + raise AssertionError("cancelled collection must not register unpublished evidence") + + +class _Context: + def __init__(self, workspace: Path, settings: dict[str, object] | None = None) -> None: + self.workspace = workspace + self.temporary_directory = workspace / "tmp" + self.settings = settings or {} + self.artifacts = _RejectingArtifacts() + self.cancelled = False + + @property + def is_cancelled(self) -> bool: + return self.cancelled + + def report_progress(self, _event: str, **_metrics: int | float | str) -> None: + return None + + +class CollectorCancellationTests(unittest.TestCase): + def test_sensitive_copy_cancellation_removes_unpublished_bytes(self) -> None: + """Cancellation after a concurrent sensitive copy leaves no unregistered evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source_root = root / "source" + source_root.mkdir() + (source_root / "password.txt").write_text("sensitive", encoding="utf-8") + context = _Context(root / "workspace", {"root": str(source_root), "max_directories": 10, + "max_matches": 10}) + context.workspace.mkdir() + + def cancel_after_copy(source: Path, destination: Path) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(source.read_bytes()) + context.cancelled = True + return destination + + with patch.object(sensitive_file_inventory, "_copy", side_effect=cancel_after_copy): + result = sensitive_file_inventory.SensitiveFileInventoryCollector().collect(context) + + self.assertEqual(CollectorStatus.CANCELLED, result.status) + self.assertEqual([], [path for path in context.workspace.rglob("*") if path.is_file()]) + + def test_browser_and_media_copy_cancellation_remove_unpublished_bytes(self) -> None: + """Source-data collectors clean copied bytes when cancellation arrives during a file copy.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + browser_context = _Context(root / "browser-workspace") + browser_context.workspace.mkdir() + local = root / "local" + roaming = root / "roaming" + profile = local / "Google" / "Chrome" / "User Data" / "Default" + profile.mkdir(parents=True) + (profile / "History").write_bytes(b"history") + + def cancel_browser_copy(_source: Path, destination: Path, copied_bytes: int): + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"history") + browser_context.cancelled = True + return destination, copied_bytes + 7 + + with patch.dict(os.environ, {"LOCALAPPDATA": str(local), "APPDATA": str(roaming)}), patch.object( + browser_data_backup, "_copy_file", side_effect=cancel_browser_copy): + browser_result = browser_data_backup.BrowserDataBackupCollector().collect(browser_context) + self.assertEqual(CollectorStatus.CANCELLED, browser_result.status) + self.assertEqual([], [path for path in browser_context.workspace.rglob("*") if path.is_file()]) + + home = root / "home" + pictures = home / "Pictures" + pictures.mkdir(parents=True) + (pictures / "photo.jpg").write_bytes(b"photo") + media_context = _Context(root / "media-workspace") + media_context.workspace.mkdir() + + def cancel_media_copy(source: Path, destination: Path) -> None: + destination.write_bytes(source.read_bytes()) + media_context.cancelled = True + + with patch.object(media_backup.Path, "home", return_value=home), patch.object( + media_backup.shutil, "copy2", side_effect=cancel_media_copy): + media_result = media_backup.MediaBackupCollector().collect(media_context) + self.assertEqual(CollectorStatus.CANCELLED, media_result.status) + self.assertEqual([], [path for path in media_context.workspace.rglob("*") if path.is_file()]) + + def test_event_queries_cannot_publish_after_cancellation(self) -> None: + """All bounded event-log queries recheck cancellation after PowerShell exits.""" + collectors = ( + (application_events, application_events.ApplicationEventsCollector), + (security_events, security_events.SecurityEventsCollector), + (system_events, system_events.SystemEventsCollector), + ) + with tempfile.TemporaryDirectory() as temporary: + for module, collector_type in collectors: + with self.subTest(collector=collector_type.__name__): + workspace = Path(temporary) / collector_type.__name__ + workspace.mkdir() + context = _Context(workspace) + + def finish_after_cancellation(*_args, **_kwargs): + context.cancelled = True + return subprocess.CompletedProcess([], 0, "header\nrow\n", "") + + with patch.object(module.subprocess, "run", side_effect=finish_after_cancellation): + result = collector_type().collect(context) + self.assertEqual(CollectorStatus.CANCELLED, result.status) + self.assertEqual([], [path for path in workspace.rglob("*") if path.is_file()]) + + def test_system_data_and_ssh_copy_cancellation_remove_partial_backups(self) -> None: + """System-data and archive loops remove private copies that were never registered.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + windows = root / "Windows" + event_log = windows / "System32" / "winevt" / "Logs" / "System.evtx" + event_log.parent.mkdir(parents=True) + event_log.write_bytes(b"event log") + system_context = _Context(root / "system-workspace") + system_context.workspace.mkdir() + + def cancel_system_copy(source: Path, destination: Path) -> None: + destination.write_bytes(source.read_bytes()) + system_context.cancelled = True + + with patch.dict(os.environ, {"SystemRoot": str(windows), "ProgramData": str(root / "ProgramData")}), \ + patch.object(windows_system_data_backup.shutil, "copy2", side_effect=cancel_system_copy): + system_result = windows_system_data_backup.WindowsSystemDataBackupCollector().collect(system_context) + self.assertEqual(CollectorStatus.CANCELLED, system_result.status) + self.assertEqual([], [path for path in system_context.workspace.rglob("*") if path.is_file()]) + + home = root / "home" + private_key = home / ".ssh" / "id_test" + private_key.parent.mkdir(parents=True) + private_key.write_bytes(b"private key") + ssh_context = _Context(root / "ssh-workspace") + ssh_context.workspace.mkdir() + original_write = ssh_backup.zipfile.ZipFile.write + + def cancel_archive_write(archive, *args, **kwargs): + result = original_write(archive, *args, **kwargs) + ssh_context.cancelled = True + return result + + with patch.object(ssh_backup.Path, "home", return_value=home), patch.object( + ssh_backup.zipfile.ZipFile, "write", new=cancel_archive_write): + ssh_result = ssh_backup.SshBackupCollector().collect(ssh_context) + self.assertEqual(CollectorStatus.CANCELLED, ssh_result.status) + self.assertFalse((ssh_context.workspace / "ssh_backup.zip").exists()) + + def test_packet_and_memory_loops_stop_before_serialization(self) -> None: + """Capture and memory enumeration honor cancellation before materializing reports.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + packet_context = _Context(root / "packet", {"packet_count": 2, "timeout_seconds": 2, + "retry_window_seconds": 0, "interface": "127.0.0.1"}) + packet_context.workspace.mkdir() + + class FakeSocket: + def bind(self, _address) -> None: + return None + + def setsockopt(self, *_args) -> None: + return None + + def ioctl(self, *_args) -> None: + return None + + def close(self) -> None: + return None + + def cancel_wait(*_args): + packet_context.cancelled = True + return [], [], [] + + with patch.object(packet_capture.socket, "socket", return_value=FakeSocket()), patch.object( + packet_capture.select, "select", side_effect=cancel_wait): + packet_result = packet_capture.PacketCaptureCollector().collect(packet_context) + self.assertEqual(CollectorStatus.CANCELLED, packet_result.status) + self.assertFalse((packet_context.workspace / "packet_capture.csv").exists()) + + memory_context = _Context(root / "memory", {"max_regions": 10, "output_limit_bytes": 4096, + "disk_safety_margin_bytes": 0, + "dump_directory": "maps"}) + memory_context.workspace.mkdir() + + class FakeKernel: + def GetCurrentProcess(self): + memory_context.cancelled = True + return 1 + + class FakePsapi: + @staticmethod + def GetProcessMemoryInfo(*_args): + return 1 + + with patch.object(memory_map.ctypes, "WinDLL", side_effect=(FakeKernel(), FakePsapi())): + memory_result = memory_map.MemoryMapCollector().collect(memory_context) + self.assertEqual(CollectorStatus.CANCELLED, memory_result.status) + self.assertEqual([], [path for path in memory_context.workspace.rglob("*") if path.is_file()]) + + +if __name__ == "__main__": + unittest.main() From 4082948cff48af95a0ec13da5c5f6b157f0624d9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 17:29:04 +0300 Subject: [PATCH 164/316] feat: expose per-collector lifecycle details --- README.md | 13 +++-- TODO.md | 2 +- logicytics/__init__.py | 3 +- logicytics/api.py | 84 +++++++++++++++++++++++++++++++- logicytics/cli.py | 14 ++++++ tests/test_core_functionality.py | 74 ++++++++++++++++++++++++++++ 6 files changed, 183 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2c3bf141..53094fab 100644 --- a/README.md +++ b/README.md @@ -151,14 +151,21 @@ request = RunRequest(max_workers=1, acknowledge_authorization=True) plan = plan_run(root, request, configuration=configuration) outcome = run_collection(root, request, configuration=configuration) snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) +for collector in snapshot.collectors: + print(collector.collector_id, collector.status, collector.duration_seconds, collector.summary) + if collector.failure is not None: + print(collector.failure.operation, collector.failure.remediation) contents = read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration) ``` Planning enforces strict core/plugin preflight and configured worker bounds without creating evidence. Run queries validate the manifest, collector-owned -artifact catalog, and configured output boundaries. Artifact reads are bounded -(16 MiB by default, never more than 64 MiB) and verify the exact registered -bytes against their manifest SHA-256 before returning evidence. +artifact catalog, per-collector timestamps, durations, summaries, errors, +actionable failure details, and configured output boundaries. The command-line +run summary prints the same redacted per-collector status, duration, summary, +and failure guidance. Artifact reads are bounded (16 MiB by default, never more +than 64 MiB) and verify the exact registered bytes against their manifest +SHA-256 before returning evidence. ## Configuration diff --git a/TODO.md b/TODO.md index 207f85e1..8173d5a8 100644 --- a/TODO.md +++ b/TODO.md @@ -575,7 +575,7 @@ Windows platform allows. - [ ] Exclude engine/library directories and underscore-prefixed helper files from automatic collector discovery. - [x] Support a configurable worker limit for parallel collection. -- [ ] Provide per-collector success, failure, and duration information. +- [x] Provide per-collector success, failure, and duration information. ### Execution modes diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 26e6593e..46617a78 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -18,7 +18,7 @@ ) _APPLICATION_EXPORTS = frozenset({ - "CollectorSnapshot", "RunSnapshot", "load_configuration", "plan_run", "query_run", "read_artifact", + "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "plan_run", "query_run", "read_artifact", "run_collection", }) @@ -39,6 +39,7 @@ def __getattr__(name: str): "CollectionEstimate", "CollectorMetadata", "CollectorResult", + "CollectorFailureSnapshot", "CollectorSnapshot", "CoreCollector", "EvidenceKind", diff --git a/logicytics/api.py b/logicytics/api.py index 62f6d583..195fafe4 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -7,6 +7,7 @@ import re from dataclasses import dataclass from datetime import datetime +from math import isfinite from pathlib import Path, PurePosixPath from typing import Any @@ -24,12 +25,29 @@ _DEFAULT_ARTIFACT_READ_BYTES = 16 * 1024 * 1024 +@dataclass(frozen=True, slots=True) +class CollectorFailureSnapshot: + """Immutable actionable failure details from one persisted collector result.""" + + collector_id: str + operation: str + platform_error: str + remediation: str + retry_safe: bool + + @dataclass(frozen=True, slots=True) class CollectorSnapshot: - """Immutable public status for one collector recorded in a persisted run.""" + """Immutable public lifecycle details for one collector in a persisted run.""" collector_id: str status: str + started_at: str | None + finished_at: str | None + duration_seconds: float | None + summary: str | None + errors: tuple[str, ...] + failure: CollectorFailureSnapshot | None @dataclass(frozen=True, slots=True) @@ -150,6 +168,68 @@ def _manifest_timestamp(value: object, field: str, *, optional: bool = False) -> return value +def _collector_snapshot(record: dict[str, Any], collector_id: str, status: str) -> CollectorSnapshot: + """Validate and freeze persisted per-collector timing and failure information.""" + started_at = _manifest_timestamp(record.get("started_at"), "collector started_at", optional=True) + finished_at = _manifest_timestamp(record.get("finished_at"), "collector finished_at", optional=True) + duration = record.get("duration_seconds") + if duration is not None and ( + not isinstance(duration, (int, float)) + or isinstance(duration, bool) + or not isfinite(duration) + or duration < 0 + ): + raise PlanError("run manifest collector duration_seconds must be a finite non-negative number or null") + summary = record.get("summary") + if summary is not None and (not isinstance(summary, str) or not summary.strip()): + raise PlanError("run manifest collector summary must be a non-empty string or null") + raw_errors = record.get("errors") + if not isinstance(raw_errors, list) or not all(isinstance(error, str) and error.strip() for error in raw_errors): + raise PlanError("run manifest collector errors must be an array of non-empty strings") + terminal = status in {item.value for item in CollectorStatus} + if terminal and (finished_at is None or summary is None): + raise PlanError("finalized collector records require finished_at and summary") + if not terminal and (finished_at is not None or duration is not None): + raise PlanError("planned or running collector records cannot contain terminal timing") + if started_at is None and duration is not None: + raise PlanError("collector duration_seconds requires a started_at timestamp") + + raw_failure = record.get("failure") + failure: CollectorFailureSnapshot | None = None + if raw_failure is not None: + required = {"collector_id", "operation", "platform_error", "remediation", "retry_safe"} + if not isinstance(raw_failure, dict) or set(raw_failure) != required: + raise PlanError("run manifest collector failure must contain the complete actionable failure contract") + if ( + raw_failure.get("collector_id") != collector_id + or not all( + isinstance(raw_failure.get(field), str) and raw_failure[field].strip() + for field in ("operation", "platform_error", "remediation") + ) + or not isinstance(raw_failure.get("retry_safe"), bool) + ): + raise PlanError("run manifest collector failure contains invalid actionable details") + failure = CollectorFailureSnapshot( + collector_id=collector_id, + operation=raw_failure["operation"], + platform_error=raw_failure["platform_error"], + remediation=raw_failure["remediation"], + retry_safe=raw_failure["retry_safe"], + ) + if (status == CollectorStatus.FAILED.value) != (failure is not None): + raise PlanError("run manifest collector failure details must match failed status") + return CollectorSnapshot( + collector_id=collector_id, + status=status, + started_at=started_at, + finished_at=finished_at, + duration_seconds=None if duration is None else float(duration), + summary=summary, + errors=tuple(raw_errors), + failure=failure, + ) + + def query_run( project_root: Path | str, run_id: str, @@ -207,7 +287,7 @@ def query_run( ): raise PlanError("run manifest contains an invalid or duplicate collector record") collector_ids.add(collector_id) - collectors.append(CollectorSnapshot(collector_id, collector_status)) + collectors.append(_collector_snapshot(record, collector_id, collector_status)) for raw in record["artifacts"]: artifact = _artifact(raw, collector_id) if artifact.id in artifact_ids: diff --git a/logicytics/cli.py b/logicytics/cli.py index 53394281..50492a9b 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -195,6 +195,20 @@ def main(argv: list[str] | None = None) -> int: print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) return 0 outcome = RunSupervisor(root, configuration).run(plan) + print("Collectors:") + for record in outcome.manifest.collectors: + duration = "not-started" if record.duration_seconds is None else f"{record.duration_seconds:.3f}" + print( + f"- {record.id} status={record.status} duration_seconds={duration} " + f"summary={record.summary or 'not-finished'}" + ) + if record.failure is not None: + print( + f" failure operation={record.failure['operation']} " + f"retry_safe={str(record.failure['retry_safe']).lower()} " + f"platform_error={record.failure['platform_error']} " + f"remediation={record.failure['remediation']}" + ) if arguments.performance_check: performance_path = outcome.run_directory / "logs" / "performance.json" print(f"Performance: {performance_path}") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index ce1ea3eb..df45c7de 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -229,6 +229,14 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> self.assertEqual(outcome.manifest_path, snapshot.manifest_path) self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) + collector = snapshot.collectors[0] + self.assertIsNotNone(collector.started_at) + self.assertIsNotNone(collector.finished_at) + self.assertIsNotNone(collector.duration_seconds) + self.assertGreaterEqual(collector.duration_seconds, 0) + self.assertEqual("test artifact created", collector.summary) + self.assertEqual((), collector.errors) + self.assertIsNone(collector.failure) self.assertEqual(1, len(snapshot.artifacts)) self.assertEqual( b"ok" + os.linesep.encode("ascii"), @@ -236,6 +244,47 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> ) with self.assertRaises(FrozenInstanceError): snapshot.status = RunStatus.FAILED + with self.assertRaises(FrozenInstanceError): + collector.status = "failed" + + def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration(self) -> None: + """Callers can inspect redacted lifecycle timing and actionable failure details without parsing manifests.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + _COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' raise RuntimeError("fixture lifecycle failure")\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) + snapshot = query_run(root, outcome.manifest.run_id) + collector = snapshot.collectors[0] + + self.assertEqual("failed", collector.status) + self.assertIsNotNone(collector.duration_seconds) + self.assertGreaterEqual(collector.duration_seconds, 0) + self.assertIn("worker crashed", collector.summary) + self.assertTrue(any("fixture lifecycle failure" in error for error in collector.errors)) + self.assertEqual("core.system.system_info", collector.failure.collector_id) + self.assertEqual("collect", collector.failure.operation) + self.assertIn("fixture lifecycle failure", collector.failure.platform_error) + self.assertTrue(collector.failure.remediation) + self.assertTrue(collector.failure.retry_safe) + + collector_path.write_text(_COLLECTOR, encoding="utf-8") + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + exit_code = main(["run", "--default", "--acknowledge-authorization"]) + self.assertEqual(0, exit_code, output.getvalue()) + self.assertIn("Collectors:", output.getvalue()) + self.assertIn("core.system.system_info status=succeeded duration_seconds=", output.getvalue()) + self.assertIn("summary=test artifact created", output.getvalue()) def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links(self) -> None: """Persisted status lookup cannot leave its configured run or trust a forged manifest.""" @@ -271,6 +320,31 @@ def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links( outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") with self.assertRaisesRegex(PlanError, message): query_run(root, outcome.manifest.run_id) + invalid_collector_fields = ( + ("started_at", "not-a-timestamp", "collector started_at"), + ("finished_at", None, "require finished_at"), + ("duration_seconds", -1, "duration_seconds"), + ("summary", "", "summary"), + ("errors", [""], "errors"), + ( + "failure", + { + "collector_id": "core.system.system_info", + "operation": "collect", + "platform_error": "forged", + "remediation": "forged", + "retry_safe": True, + }, + "must match failed status", + ), + ) + for field, value, message in invalid_collector_fields: + with self.subTest(collector_field=field): + payload = json.loads(original) + payload["collectors"][0][field] = value + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + query_run(root, outcome.manifest.run_id) payload = json.loads(original) payload["collectors"][0]["id"] = "../outside" payload["resolved_plan"] = ["../outside"] From febe68e9cb651fd15b4ad2e0bbe64aafe81d82fe Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 26 Aug 2026 23:34:24 +0300 Subject: [PATCH 165/316] feat: version run manifest schema --- README.md | 10 +++++++--- TODO.md | 2 +- logicytics/api.py | 11 +++++++++++ logicytics/cli.py | 11 +++++++++++ logicytics/manifest.py | 12 ++++++++++++ tests/test_core_functionality.py | 24 ++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 53094fab..d19b2348 100644 --- a/README.md +++ b/README.md @@ -84,9 +84,13 @@ Each verified ZIP uses a versioned, non-overlapping layout. Registered source material is stored under `evidence/raw/`, generated collector output under `evidence/derived/`, the human summary under `reports/`, structured diagnostics under `logs/`, the per-artifact SHA-256 catalog under `hashes/`, and the -machine-readable run manifest under `metadata/`. The manifest records both the -layout version and every section path; the package SHA-256 remains an external -sidecar because an archive cannot contain its own final digest. +machine-readable run manifest under `metadata/`. Manifest schema version `1` +records run identity, action, status, timestamps, the redacted request and +configuration, host facts, resolved plan and fingerprint, collector lifecycle +records, the artifact catalog, errors, and package metadata. Readers fail closed +on absent, non-integer, or unsupported schema versions. The manifest separately +records package layout version `1.0` and every section path; the package SHA-256 +remains an external sidecar because an archive cannot contain its own final digest. ## Requirements diff --git a/TODO.md b/TODO.md index 8173d5a8..62373617 100644 --- a/TODO.md +++ b/TODO.md @@ -877,7 +877,7 @@ appropriate default/deep/non-Python mode. ### Phase 1 — Foundation -- [ ] Define the v4.0 package layout and run manifest format. +- [x] Define the v4.0 package layout and run manifest format. - [ ] Implement configuration loading, structured logging, file discovery, command execution, privilege checks, cancellation, and error aggregation. - [ ] Implement the temporary run directory and cleanup lifecycle. diff --git a/logicytics/api.py b/logicytics/api.py index 195fafe4..f496ff9d 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -15,6 +15,7 @@ from logicytics.contracts import Artifact, CollectorStatus, RunRequest, RunStatus from logicytics.discovery import preflight from logicytics.errors import ArtifactError, PlanError +from logicytics.manifest import MANIFEST_SCHEMA_VERSION from logicytics.planner import RunPlan, build_plan from logicytics.runtime import RunOutcome, RunSupervisor @@ -254,6 +255,16 @@ def query_run( raise PlanError(f"unable to read run manifest for {run_id}: {error}") from error if not isinstance(payload, dict) or payload.get("run_id") != run_id: raise PlanError("run manifest identity does not match its owned directory") + manifest_schema_version = payload.get("manifest_schema_version") + if ( + not isinstance(manifest_schema_version, int) + or isinstance(manifest_schema_version, bool) + or manifest_schema_version != MANIFEST_SCHEMA_VERSION + ): + raise PlanError( + f"unsupported run manifest schema_version {manifest_schema_version!r}; " + f"expected {MANIFEST_SCHEMA_VERSION}" + ) _manifest_timestamp(payload.get("requested_at"), "requested_at") if not isinstance(payload.get("status"), str): raise PlanError("run manifest contains an unsupported run status") diff --git a/logicytics/cli.py b/logicytics/cli.py index 50492a9b..b2dfd6c4 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -16,6 +16,7 @@ from logicytics.discovery import preflight from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError +from logicytics.manifest import MANIFEST_SCHEMA_VERSION from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -53,6 +54,16 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: raise ValueError(f"original run manifest cannot be loaded: {error}") from error if not isinstance(previous, dict) or not isinstance(previous.get("run_id"), str): raise ValueError("original run manifest must contain a valid run_id") + manifest_schema_version = previous.get("manifest_schema_version") + if ( + not isinstance(manifest_schema_version, int) + or isinstance(manifest_schema_version, bool) + or manifest_schema_version != MANIFEST_SCHEMA_VERSION + ): + raise ValueError( + f"original run manifest uses unsupported schema_version {manifest_schema_version!r}; " + f"expected {MANIFEST_SCHEMA_VERSION}" + ) if previous.get("status") not in {"succeeded", "partial", "failed", "cancelled"}: raise ValueError("original run manifest must describe a finalized run") resolved = previous.get("resolved_plan") diff --git a/logicytics/manifest.py b/logicytics/manifest.py index eed5f410..d650134c 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -15,6 +15,8 @@ from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus from logicytics.redaction import redact_mapping, redact_text +MANIFEST_SCHEMA_VERSION = 1 + def utc_now() -> str: """Return a stable UTC timestamp for manifests and records.""" @@ -117,6 +119,7 @@ class RunManifest: configuration: Mapping[str, Any] collectors: list[CollectorRecord] host: Mapping[str, str] + manifest_schema_version: int = MANIFEST_SCHEMA_VERSION resolved_plan: tuple[str, ...] = () plan_fingerprint: str | None = None engine_version: str = CONTRACT_VERSION @@ -222,6 +225,15 @@ def artifact_catalog(self) -> tuple[dict[str, Any], ...]: def to_dict(self) -> dict[str, Any]: """Produce JSON-safe manifest data.""" + if ( + not isinstance(self.manifest_schema_version, int) + or isinstance(self.manifest_schema_version, bool) + or self.manifest_schema_version != MANIFEST_SCHEMA_VERSION + ): + raise ValueError( + f"unsupported run manifest schema_version {self.manifest_schema_version!r}; " + f"expected {MANIFEST_SCHEMA_VERSION}" + ) data = asdict(self) data["status"] = self.status.value data["artifact_catalog"] = self.artifact_catalog() diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index df45c7de..7ec6dfff 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -227,6 +227,7 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> self.assertEqual(RunStatus.SUCCEEDED, snapshot.status, outcome.manifest.package) self.assertEqual(outcome.run_directory, snapshot.run_directory) self.assertEqual(outcome.manifest_path, snapshot.manifest_path) + self.assertEqual(1, json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"]) self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) collector = snapshot.collectors[0] @@ -307,6 +308,8 @@ def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links( with self.assertRaisesRegex(PlanError, "identity"): query_run(root, outcome.manifest.run_id) invalid_manifests = ( + ("manifest_schema_version", 2, "unsupported run manifest schema_version"), + ("manifest_schema_version", True, "unsupported run manifest schema_version"), ("status", [], "unsupported run status"), ("resolved_plan", ["core.system.forged"], "resolved plan"), ("total_artifact_bytes", 999, "total_artifact_bytes"), @@ -320,6 +323,11 @@ def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links( outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") with self.assertRaisesRegex(PlanError, message): query_run(root, outcome.manifest.run_id) + payload = json.loads(original) + payload.pop("manifest_schema_version") + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "unsupported run manifest schema_version"): + query_run(root, outcome.manifest.run_id) invalid_collector_fields = ( ("started_at", "not-a-timestamp", "collector started_at"), ("finished_at", None, "require finished_at"), @@ -1208,6 +1216,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( manifest_path = root / "manifest.json" collector_id = "core.system.system_info" valid_manifest = { + "manifest_schema_version": 1, "run_id": "run-" + "a" * 32, "status": "succeeded", "resolved_plan": [collector_id], @@ -1230,6 +1239,17 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), 2, ) + for schema_version in (None, True, 2): + with self.subTest(manifest_schema_version=schema_version): + invalid_manifest = {**valid_manifest, "manifest_schema_version": schema_version} + manifest_path.write_text(json.dumps(invalid_manifest), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unsupported schema_version"): + _request( + parser.parse_args( + ["run", "--rerun-from", str(manifest_path), "--include", collector_id] + ), + 2, + ) manifest_path.write_text("not json", encoding="utf-8") with self.assertRaisesRegex(ValueError, "cannot be loaded"): _request( @@ -1769,12 +1789,16 @@ def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) self.assertEqual(artifact.sha256, hashlib.sha256(archive.read(archive_name)).hexdigest()) packaged = json.loads(archive.read("metadata/manifest.json")) self.assertEqual("1.0", packaged["package_layout_version"]) + self.assertEqual(1, packaged["manifest_schema_version"]) self.assertEqual("evidence/raw/", packaged["package_sections"]["raw_evidence"]) self.assertEqual("evidence/derived/", packaged["package_sections"]["derived_reports"]) self.assertEqual( {"raw", "derived"}, {item["evidence_kind"] for item in packaged["artifact_catalog"]}, ) + outcome.manifest.manifest_schema_version = 2 + with self.assertRaisesRegex(ValueError, "unsupported run manifest schema_version"): + outcome.manifest.to_dict() def test_configured_output_root_colocates_packages_without_touching_legacy_evidence(self) -> None: """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" From 4952f282ecc6fa92aaecf870a610e4927a37f07c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:19:06 +0300 Subject: [PATCH 166/316] docs: reconcile completed v4 architecture tasks Mark architecture requirements that are already enforced by the immutable contracts, strict preflight, isolated worker runtime, bounded scheduler, redaction layer, public API, and existing test coverage. Rename the checklist's injected run context reference to CollectorContext so the completed item matches the implemented public contract without changing its intent. --- TODO.md | 46 +++++++++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/TODO.md b/TODO.md index 62373617..42c6454b 100644 --- a/TODO.md +++ b/TODO.md @@ -235,7 +235,7 @@ that fails any mandatory rule is not a collector and must not be launched. has a second public class that could be mistaken for the collector. - [x] Require a stable, lowercase dotted ID matching its location and class, such as `core.network.network_info` or `plugin.example_plugin`. -- [ ] Require class metadata constants or an immutable metadata object for: +- [x] Require class metadata constants or an immutable metadata object for: `ID`, `NAME`, `VERSION`, `SPECIALTY`, `DESCRIPTION`, `AUTHOR`, and `SUPPORTED_PLATFORMS`. - [ ] Require plugin metadata to additionally declare requested capabilities, @@ -275,7 +275,7 @@ that fails any mandatory rule is not a collector and must not be launched. references; direct package manipulation is forbidden. - [x] Require collectors to report structured progress and never use undocumented console output as their API. -- [ ] Require all expected failures to become typed result/error values rather than +- [x] Require all expected failures to become typed result/error values rather than process exits. - [x] Require docstrings for the class and every public method. - [x] Reject import-time collection, threads, subprocesses, network calls, file @@ -284,13 +284,13 @@ that fails any mandatory rule is not a collector and must not be launched. #### Static and runtime preflight validation - [x] Parse every discovered module with the Python AST before importing it. -- [ ] Validate path, filename, module docstring, imports, public names, class +- [x] Validate path, filename, module docstring, imports, public names, class inheritance, class name, metadata, method signatures, type annotations, and forbidden top-level statements. - [x] Import each candidate in a short-lived validation subprocess with collection disabled and with a restricted environment. - [x] Instantiate the class only after static validation succeeds. -- [ ] Validate metadata, dependencies, supported platform, requested capabilities, +- [x] Validate metadata, dependencies, supported platform, requested capabilities, output declarations, timeout, and size limits at runtime. - [x] Run a no-op contract probe against `validate()` and reject unexpected side effects, hangs, exceptions, or malformed return values. @@ -320,11 +320,11 @@ Windows platform allows. do not share mutable engine objects across collectors. - [x] Enforce declared timeout, memory/output limits, file-count limits, and cancellation through the supervisor. -- [ ] Terminate and clean up only the failed collector's process and workspace when +- [x] Terminate and clean up only the failed collector's process and workspace when it crashes, hangs, exceeds limits, or is cancelled. -- [ ] Mark that collector `failed`/`cancelled` with its traceback stored in the +- [x] Mark that collector `failed`/`cancelled` with its traceback stored in the run manifest, while allowing independent collectors to finish. -- [ ] Prevent a collector from changing another collector's files, result state, +- [x] Prevent a collector from changing another collector's files, result state, logger configuration, environment, or execution plan. - [x] Restrict artifact registration to paths inside the collector workspace and copy/stream only approved artifacts into the run artifact store. @@ -334,7 +334,7 @@ Windows platform allows. - [x] Require elevated privilege and explicit user approval for collectors that need it; do not grant administrator access to every plugin by default. - [x] Capture subprocess trees so child processes do not survive a failed collector. -- [ ] Use OS/process isolation as the default boundary; treat Python-level import +- [x] Use OS/process isolation as the default boundary; treat Python-level import restrictions as validation and defense-in-depth, not as a complete sandbox. - [x] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter configuration, install packages, update the repository, or launch another @@ -352,12 +352,12 @@ Windows platform allows. collector registry. Each collector declares its ID, category, supported platforms, required privileges, estimated cost, dependencies, output types, default profiles, and whether it is safe to parallelize. -- [ ] Replace scripts writing directly into the repository with a `RunContext` that +- [x] Replace scripts writing directly into the repository with a `CollectorContext` that gives each collector a run ID, isolated workspace, logger, configuration snapshot, artifact writer, and cancellation handle. -- [ ] Replace implicit current-working-directory behavior with explicit absolute +- [x] Replace implicit current-working-directory behavior with explicit absolute paths owned by the run context. -- [ ] Replace `print`, `exit`, and process-level shutdowns inside collectors with +- [x] Replace `print`, `exit`, and process-level shutdowns inside collectors with typed results and typed errors returned to the orchestrator. - [x] Replace one giant execution-list algorithm with a planner that resolves collector dependencies, removes duplicates, validates conflicts, and emits a @@ -365,14 +365,14 @@ Windows platform allows. - [ ] Replace mode names that imply implementation details with user-facing collection profiles. Preserve compatibility aliases for existing flags, but make profiles the canonical internal concept. -- [ ] Replace a single shared mutable logger with run-scoped structured logging +- [x] Replace a single shared mutable logger with run-scoped structured logging that can still be rendered to the existing console/log-file formats. - [x] Replace ad hoc output filenames with an artifact catalog containing stable artifact IDs, human-readable names, MIME/type information, byte counts, timestamps, producer collector, and status. - [x] Replace “zip whatever happens to be in CODE” with packaging the run's artifact catalog and manifest only. -- [ ] Replace cleanup based on deleting a discovered file list with ownership-based +- [x] Replace cleanup based on deleting a discovered file list with ownership-based cleanup that can remove only the current run's temporary workspace. ### Define the v4.0 run model @@ -400,11 +400,11 @@ Windows platform allows. ### Separate responsibilities into layers -- [ ] **CLI layer:** parse arguments, show help, request confirmation, and render +- [x] **CLI layer:** parse arguments, show help, request confirmation, and render results; do not collect data or manipulate package files directly. -- [ ] **Application layer:** create the request, resolve the profile, plan the run, +- [x] **Application layer:** create the request, resolve the profile, plan the run, coordinate collectors, and decide the final run status. -- [ ] **Collector layer:** perform one focused collection task through injected +- [x] **Collector layer:** perform one focused collection task through injected services; do not import the main application or depend on global state. - [ ] **Platform layer:** centralize PowerShell, WMIC/WMI, registry, filesystem, process, network, and privilege access behind testable adapters. @@ -432,7 +432,7 @@ Windows platform allows. memory when streaming is possible. - [x] Redact secrets from logs and from diagnostic metadata even when the artifact itself is intentionally collected. -- [ ] Never place credentials or raw sensitive evidence in console logs, exception +- [x] Never place credentials or raw sensitive evidence in console logs, exception messages, temporary filenames, or update requests. - [x] Record resource usage and progress per collector: files scanned, files copied, bytes written, packets observed, events processed, and elapsed time. @@ -441,7 +441,7 @@ Windows platform allows. ### Make concurrency a planned capability -- [ ] Use a scheduler with bounded workers instead of creating independent thread +- [x] Use a scheduler with bounded workers instead of creating independent thread pools inside collectors. - [x] Declare dependencies and resource classes so disk-heavy, network-heavy, registry-sensitive, and interactive collectors can be coordinated. @@ -470,7 +470,7 @@ Windows platform allows. ### Improve the extension model -- [ ] Define a stable `Collector` interface and version it. +- [x] Define a stable `Collector` interface and version it. - [ ] Require every MODS collector to declare metadata rather than being discovered only by extension. - [ ] Run mods in a sandboxed/isolated run workspace with declared inputs and @@ -503,16 +503,16 @@ Windows platform allows. - [x] Make the package importable without starting a collection, opening files, or requiring Windows-only APIs at import time. -- [ ] Replace singleton-heavy APIs with dependency injection where state matters. +- [x] Replace singleton-heavy APIs with dependency injection where state matters. - [x] Keep a small stable public API: configuration, planning, running, querying run status, and reading artifacts. -- [ ] Keep platform adapters mockable so collectors can be tested on non-Windows +- [x] Keep platform adapters mockable so collectors can be tested on non-Windows development machines. - [ ] Add contract tests for every collector's metadata, lifecycle, result states, artifact declarations, and error behavior. -- [ ] Add planner tests for profiles, explicit selections, dependencies, conflicts, +- [x] Add planner tests for profiles, explicit selections, dependencies, conflicts, duplicate collectors, and invalid configurations. -- [ ] Add run tests for sequential, bounded-parallel, cancellation, timeout, +- [x] Add run tests for sequential, bounded-parallel, cancellation, timeout, partial-failure, rerun, and cleanup behavior. - [x] Add packaging tests proving that unregistered files cannot enter a package and that manifest hashes match the final bytes. From 0895a9f8d599326f52f75ffdf1c1b0cf0b837afc Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:25:54 +0300 Subject: [PATCH 167/316] feat: cache validated collector preflight probes Persist successful runtime probe metadata in the operating-system temporary area and reuse it only when the project path, collector source digest, Python interpreter identity, v4 contract version, and complete validated configuration fingerprint all match. Continue running static AST validation on every preflight, atomically refresh the cache, route CLI and public API planning through configuration-aware fingerprints, and cover reuse plus invalidation with an integration test. --- TODO.md | 2 +- logicytics/api.py | 2 +- logicytics/cli.py | 2 +- logicytics/configuration.py | 8 ++ logicytics/discovery.py | 130 +++++++++++++++++++++++++------ tests/test_core_functionality.py | 20 +++++ 6 files changed, 138 insertions(+), 26 deletions(-) diff --git a/TODO.md b/TODO.md index 42c6454b..eb5f17db 100644 --- a/TODO.md +++ b/TODO.md @@ -300,7 +300,7 @@ that fails any mandatory rule is not a collector and must not be launched. plugin fails validation. - [x] Never “best effort” execute a malformed collector because another collector succeeded. -- [ ] Cache validation only with a source hash, interpreter version, contract +- [x] Cache validation only with a source hash, interpreter version, contract version, and configuration hash; invalidate the cache whenever any changes. ### Per-collector sandbox and failure isolation diff --git a/logicytics/api.py b/logicytics/api.py index f496ff9d..2bb2b75f 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -110,7 +110,7 @@ def plan_run( raise PlanError("request must be an immutable RunRequest instance") if request.max_workers > settings.runtime.maximum_workers: raise PlanError("requested workers exceed configured maximum_workers") - return build_plan(preflight(root), request) + return build_plan(preflight(root, configuration_hash=settings.fingerprint()), request) def run_collection( diff --git a/logicytics/cli.py b/logicytics/cli.py index b2dfd6c4..23ed03c1 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -160,7 +160,7 @@ def main(argv: list[str] | None = None) -> int: root = _project_root() try: configuration = load_config(root, arguments.config) - report = preflight(root) + report = preflight(root, configuration_hash=configuration.fingerprint()) if arguments.command == "preflight": validation = report.to_dict( selected_plugins=tuple(arguments.include), diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 8706db22..a196259c 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import hashlib import json import re from dataclasses import asdict, dataclass, field @@ -213,6 +214,13 @@ def to_manifest_dict(self) -> dict[str, Any]: data["runtime"]["output_root"] = str(self.runtime.output_root) return redact_mapping(data) + def fingerprint(self) -> str: + """Hash the complete validated configuration without persisting its secrets.""" + data = asdict(self) + data["runtime"]["output_root"] = str(self.runtime.output_root.resolve()) + serialized = json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(serialized.encode("utf-8")).hexdigest() + def default_config(project_root: Path) -> AppConfig: """Create safe defaults rooted at the checked-out project.""" diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 562927c2..3e9abb6e 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -3,6 +3,7 @@ from __future__ import annotations import ast +import hashlib import json import os import re @@ -21,6 +22,7 @@ "load_configuration", "manifest", "packaging", "plan_run", "planner", "query_run", "read_artifact", "runtime", "run_collection", "validation_worker", } +_CACHE_SCHEMA_VERSION = 1 @dataclass(frozen=True, slots=True) @@ -426,6 +428,38 @@ def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: return tuple(candidates) +def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> None: + """Validate cached or freshly probed metadata through the same strict path.""" + try: + if not isinstance(payload, dict): + raise ValueError("metadata payload must be an object") + metadata = CollectorMetadata.from_dict( + payload, + allow_custom_specialty=candidate.kind is CollectorKind.PLUGIN, + ) + except (KeyError, TypeError, ValueError) as error: + candidate.runtime_error = f"invalid validation response: {error}" + return + if metadata.minimum_contract_version != CONTRACT_VERSION: + candidate.runtime_error = "collector contract version is unsupported" + elif metadata.timeout_seconds < 1 or metadata.maximum_output_bytes < 1: + candidate.runtime_error = "collector must declare positive timeout and output limits" + elif not metadata.id.startswith(f"{candidate.kind.value}."): + candidate.runtime_error = "collector ID must start with its owner kind" + elif candidate.kind is CollectorKind.CORE and metadata.specialty.value != candidate.path.parent.name: + candidate.runtime_error = "core collector specialty must match its parent folder" + elif candidate.kind is CollectorKind.CORE and metadata.id != ( + f"core.{candidate.path.parent.name}.{candidate.path.stem}" + ): + candidate.runtime_error = "core collector ID must match core//.py" + elif candidate.kind is CollectorKind.PLUGIN and metadata.id != ( + f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" + ): + candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" + else: + candidate.metadata = metadata + + def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: engine_root = Path(__file__).resolve().parent.parent pythonpath = os.pathsep.join((str(engine_root), str(project_root))) @@ -468,36 +502,85 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: return try: payload = json.loads(completed.stdout) - metadata = CollectorMetadata.from_dict(payload["metadata"], - allow_custom_specialty=candidate.kind is CollectorKind.PLUGIN) - except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + metadata_payload = payload["metadata"] + except (KeyError, TypeError, json.JSONDecodeError) as error: candidate.runtime_error = f"invalid validation response: {error}" return - if metadata.minimum_contract_version != CONTRACT_VERSION: - candidate.runtime_error = "collector contract version is unsupported" - elif metadata.timeout_seconds < 1 or metadata.maximum_output_bytes < 1: - candidate.runtime_error = "collector must declare positive timeout and output limits" - elif not metadata.id.startswith(f"{candidate.kind.value}."): - candidate.runtime_error = "collector ID must start with its owner kind" - elif candidate.kind is CollectorKind.CORE and metadata.specialty.value != candidate.path.parent.name: - candidate.runtime_error = "core collector specialty must match its parent folder" - elif candidate.kind is CollectorKind.CORE and metadata.id != ( - f"core.{candidate.path.parent.name}.{candidate.path.stem}" - ): - candidate.runtime_error = "core collector ID must match core//.py" - elif candidate.kind is CollectorKind.PLUGIN and metadata.id != ( - f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" - ): - candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" - else: - candidate.metadata = metadata + _accept_runtime_metadata(candidate, metadata_payload) + + +def _cache_path(project_root: Path) -> Path: + """Keep disposable validation state outside source and evidence directories.""" + project_key = hashlib.sha256(str(project_root.resolve()).encode("utf-8")).hexdigest() + return Path(tempfile.gettempdir()) / "logicytics-preflight-cache" / f"{project_key}.json" + +def _source_hash(path: Path) -> str: + """Hash exact collector bytes so any source edit invalidates its probe result.""" + return hashlib.sha256(path.read_bytes()).hexdigest() -def preflight(project_root: Path) -> PreflightReport: + +def _load_cache(project_root: Path, configuration_hash: str) -> dict[str, object]: + """Load only a cache created for this interpreter, contract, and configuration.""" + path = _cache_path(project_root) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return {} + expected = { + "schema_version": _CACHE_SCHEMA_VERSION, + "interpreter": sys.version, + "contract_version": CONTRACT_VERSION, + "configuration_hash": configuration_hash, + } + if not isinstance(payload, dict) or any(payload.get(key) != value for key, value in expected.items()): + return {} + entries = payload.get("entries") + return entries if isinstance(entries, dict) else {} + + +def _write_cache(project_root: Path, configuration_hash: str, candidates: list[CollectorCandidate]) -> None: + """Atomically persist successful probes; invalid candidates are always reprobed.""" + entries: dict[str, object] = {} + for candidate in candidates: + if candidate.metadata is None or candidate.runtime_error or candidate.static_errors: + continue + relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() + entries[relative_path] = { + "kind": candidate.kind.value, + "source_hash": _source_hash(candidate.path), + "metadata": candidate.metadata.to_dict(), + } + payload = { + "schema_version": _CACHE_SCHEMA_VERSION, + "interpreter": sys.version, + "contract_version": CONTRACT_VERSION, + "configuration_hash": configuration_hash, + "entries": entries, + } + path = _cache_path(project_root) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(path) + + +def preflight(project_root: Path, *, configuration_hash: str = "unconfigured") -> PreflightReport: """Perform static checks then a short-lived isolated metadata probe.""" candidates = list(discover(project_root)) + cached = _load_cache(project_root, configuration_hash) for candidate in candidates: - if not candidate.static_errors: + if candidate.static_errors: + continue + relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() + entry = cached.get(relative_path) + if ( + isinstance(entry, dict) + and entry.get("kind") == candidate.kind.value + and entry.get("source_hash") == _source_hash(candidate.path) + ): + _accept_runtime_metadata(candidate, entry.get("metadata")) + else: _runtime_probe(project_root, candidate) seen_ids: set[str] = set() for candidate in candidates: @@ -506,4 +589,5 @@ def preflight(project_root: Path) -> PreflightReport: if candidate.metadata.id in seen_ids: candidate.runtime_error = f"duplicate collector ID: {candidate.metadata.id}" seen_ids.add(candidate.metadata.id) + _write_cache(project_root, configuration_hash, candidates) return PreflightReport(tuple(candidates)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 7ec6dfff..19d9d0c7 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -134,6 +134,26 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity(self) -> None: + """Only an exact validation context may reuse an isolated runtime probe.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text(_COLLECTOR, encoding="utf-8") + + initial = preflight(root, configuration_hash="configuration-a") + self.assertEqual(1, len(initial.valid), initial.invalid) + with patch("logicytics.discovery.subprocess.run", wraps=subprocess.run) as probe: + cached = preflight(root, configuration_hash="configuration-a") + self.assertEqual(1, len(cached.valid), cached.invalid) + probe.assert_not_called() + + with patch("logicytics.discovery.subprocess.run", wraps=subprocess.run) as probe: + invalidated = preflight(root, configuration_hash="configuration-b") + self.assertEqual(1, len(invalidated.valid), invalidated.invalid) + self.assertEqual(1, probe.call_count) + def test_public_package_import_does_not_start_runtime_or_create_files(self) -> None: """Importing contracts exposes lazy application services without host or disk side effects.""" with tempfile.TemporaryDirectory() as temporary: From 03a0e04b8218945fb8bac80dcb8a6cc8c2d628b0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:29:22 +0300 Subject: [PATCH 168/316] feat: enforce typed output and post-run policies Extend immutable run requests with explicit package and host-action policies, expose mutually exclusive reboot and shutdown CLI switches, and reject unsafe no-package combinations before planning. Schedule Windows power actions with a sixty-second delay only after performance diagnostics and a verified successful package exist, surface scheduling failures through the normal application error path, and add coverage for exclusivity, ordering prerequisites, and the emitted shutdown command. --- TODO.md | 11 +++++---- logicytics/__init__.py | 4 ++++ logicytics/cli.py | 30 ++++++++++++++++++++++- logicytics/contracts.py | 23 ++++++++++++++++++ logicytics/runtime.py | 41 +++++++++++++++++++++++++++++++- tests/test_core_functionality.py | 29 ++++++++++++++++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) diff --git a/TODO.md b/TODO.md index eb5f17db..ab880f54 100644 --- a/TODO.md +++ b/TODO.md @@ -345,9 +345,10 @@ Windows platform allows. ### Replace the current architectural model -- [ ] Replace global action/sub-action state with an immutable `RunRequest` that +- [x] Replace global action/sub-action state with an immutable `RunRequest` that contains the selected profile, explicit collector selections, concurrency, - output policy, cancellation token, and post-run action. + output policy, and post-run action; keep the mutable cancellation handle in + the per-run `CollectorContext` rather than the immutable request. - [ ] Replace hard-coded filename lists and mode-specific branches with a typed collector registry. Each collector declares its ID, category, supported platforms, required privileges, estimated cost, dependencies, output types, @@ -593,9 +594,9 @@ Windows platform allows. ### Post-run actions -- [ ] `--reboot`: schedule a system reboot after packaging. -- [ ] `--shutdown`: schedule a system shutdown after packaging. -- [ ] Ensure post-run actions do not run prematurely during performance analysis. +- [x] `--reboot`: schedule a system reboot after packaging. +- [x] `--shutdown`: schedule a system shutdown after packaging. +- [x] Ensure post-run actions do not run prematurely during performance analysis. ## 2. Command-line and interaction features diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 46617a78..ae704169 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -9,7 +9,9 @@ CollectorResult, CoreCollector, EvidenceKind, + OutputPolicy, PluginCollector, + PostRunAction, ResourceClass, RunRequest, RunStatus, @@ -43,7 +45,9 @@ def __getattr__(name: str): "CollectorSnapshot", "CoreCollector", "EvidenceKind", + "OutputPolicy", "PluginCollector", + "PostRunAction", "ResourceClass", "RunRequest", "RunSnapshot", diff --git a/logicytics/cli.py b/logicytics/cli.py index 23ed03c1..d67a3d60 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -12,7 +12,7 @@ from pathlib import Path from logicytics.configuration import load_config -from logicytics.contracts import Capability, RunRequest +from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest from logicytics.discovery import preflight from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError @@ -83,6 +83,18 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), performance_check=performance_check, rerun_from=parent_run_id, + output_policy=( + OutputPolicy.MANIFEST_ONLY + if getattr(arguments, "no_package", False) + else OutputPolicy.PACKAGE + ), + post_run_action=( + PostRunAction.REBOOT + if getattr(arguments, "reboot", False) + else PostRunAction.SHUTDOWN + if getattr(arguments, "shutdown", False) + else PostRunAction.NONE + ), ) @@ -138,6 +150,22 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Run serially and save per-collector duration measurements.", ) + subparser.add_argument( + "--no-package", + action="store_true", + help="Finalize the run manifest without creating a ZIP package.", + ) + power_action = subparser.add_mutually_exclusive_group() + power_action.add_argument( + "--reboot", + action="store_true", + help="Schedule a reboot only after the run is packaged successfully.", + ) + power_action.add_argument( + "--shutdown", + action="store_true", + help="Schedule a shutdown only after the run is packaged successfully.", + ) subparser.add_argument( "--acknowledge-authorization", action="store_true", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index d50165dc..e609ea1f 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -51,6 +51,21 @@ class RunStatus(StrEnum): CANCELLED = "cancelled" +class OutputPolicy(StrEnum): + """How a completed run publishes its evidence.""" + + PACKAGE = "package" + MANIFEST_ONLY = "manifest_only" + + +class PostRunAction(StrEnum): + """An explicitly requested host action after durable run publication.""" + + NONE = "none" + REBOOT = "reboot" + SHUTDOWN = "shutdown" + + class EvidenceKind(StrEnum): """The package section that owns a registered evidence artifact.""" @@ -413,6 +428,8 @@ class RunRequest: approved_capabilities: tuple[Capability, ...] = () performance_check: bool = False rerun_from: str | None = None + output_policy: OutputPolicy = OutputPolicy.PACKAGE + post_run_action: PostRunAction = PostRunAction.NONE def __post_init__(self) -> None: """Reject malformed selections and execution policy before planning.""" @@ -448,6 +465,12 @@ def __post_init__(self) -> None: raise ValueError("request approved_capabilities must be a tuple of Capability values") if len(set(self.approved_capabilities)) != len(self.approved_capabilities): raise ValueError("request approved_capabilities must not contain duplicates") + if not isinstance(self.output_policy, OutputPolicy): + raise ValueError("request output_policy must be an OutputPolicy value") + if not isinstance(self.post_run_action, PostRunAction): + raise ValueError("request post_run_action must be a PostRunAction value") + if self.post_run_action is not PostRunAction.NONE and self.output_policy is not OutputPolicy.PACKAGE: + raise ValueError("post-run actions require packaged output") class EventLogger(ABC): diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 98afbbca..b391c5fd 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -31,11 +31,14 @@ CollectorContext, CollectorResult, CollectorStatus, + OutputPolicy, + PostRunAction, ResourceClass, RunStatus, ValidationResult, ) from logicytics.discovery import CollectorCandidate +from logicytics.errors import LogicyticsError from logicytics.logging import FileEventLogger from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.packaging import package_manifest @@ -483,7 +486,11 @@ def run(self, plan: RunPlan) -> RunOutcome: manifest.finalize_status() if plan.request.performance_check: self._write_performance_report(run_directory, manifest) - if self.configuration.runtime.package_completed_runs: + should_package = ( + self.configuration.runtime.package_completed_runs + and plan.request.output_policy is OutputPolicy.PACKAGE + ) + if should_package: try: package_path, hash_path = package_manifest(run_directory, manifest, manifest_path) run_logger.event( @@ -498,8 +505,40 @@ def run(self, plan: RunPlan) -> RunOutcome: run_logger.event("error", "run_packaging_failed", error_type=type(error).__name__) write_manifest(manifest_path, manifest) run_logger.event("info", "run_finished", status=manifest.status.value, artifacts=manifest.total_artifact_bytes) + if plan.request.post_run_action is not PostRunAction.NONE: + self._execute_post_run_action(plan.request.post_run_action, manifest, run_logger) return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) + @staticmethod + def _execute_post_run_action( + action: PostRunAction, + manifest: RunManifest, + run_logger: FileEventLogger, + ) -> None: + """Schedule an explicit Windows power action only after verified packaging.""" + package = manifest.package or {} + if ( + manifest.status is not RunStatus.SUCCEEDED + or not isinstance(package.get("path"), str) + or not isinstance(package.get("sha256"), str) + ): + raise LogicyticsError("post-run action requires a successful run and verified package") + flag = "/r" if action is PostRunAction.REBOOT else "/s" + try: + completed = subprocess.run( + ["shutdown", flag, "/t", "60", "/d", "p:0:0", "/c", "Logicytics run completed"], + capture_output=True, + check=False, + text=True, + ) + except OSError as error: + raise LogicyticsError(f"unable to schedule {action.value}: {error}") from error + if completed.returncode != 0: + raise LogicyticsError( + f"unable to schedule {action.value}: {completed.stderr.strip() or completed.stdout.strip()}" + ) + run_logger.event("warning", "post_run_action_scheduled", action=action.value, delay_seconds=60) + @staticmethod def _write_performance_report(run_directory: Path, manifest: RunManifest) -> None: """Finalize run-owned timing diagnostics before evidence packaging begins.""" diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 19d9d0c7..c368f911 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -35,6 +35,8 @@ CollectorResult, CollectorStatus, EvidenceKind, + OutputPolicy, + PostRunAction, ResourceClass, RunRequest, RunStatus, @@ -134,6 +136,33 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging(self) -> None: + """Power actions remain explicit and cannot run before durable package publication.""" + arguments = _parser().parse_args([ + "run", "--shutdown", "--performance-check", "--acknowledge-authorization", + ]) + request = _request(arguments, default_workers=4) + self.assertEqual(PostRunAction.SHUTDOWN, request.post_run_action) + self.assertEqual(OutputPolicy.PACKAGE, request.output_policy) + self.assertEqual(1, request.max_workers) + with self.assertRaises(SystemExit): + _parser().parse_args(["run", "--reboot", "--shutdown"]) + with self.assertRaisesRegex(ValueError, "require packaged output"): + _request(_parser().parse_args(["run", "--reboot", "--no-package"]), default_workers=1) + + with tempfile.TemporaryDirectory() as temporary: + logger = FileEventLogger(Path(temporary) / "events.jsonl", run_id="run-" + "a" * 32) + manifest = type("Manifest", (), { + "status": RunStatus.SUCCEEDED, + "package": {"path": "evidence.zip", "sha256": "a" * 64}, + })() + completed = subprocess.CompletedProcess(["shutdown"], 0, "", "") + with patch("logicytics.runtime.subprocess.run", return_value=completed) as command: + RunSupervisor._execute_post_run_action(PostRunAction.REBOOT, manifest, logger) + self.assertEqual("shutdown", command.call_args.args[0][0]) + self.assertEqual("/r", command.call_args.args[0][1]) + self.assertEqual("60", command.call_args.args[0][3]) + def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity(self) -> None: """Only an exact validation context may reuse an isolated runtime probe.""" with tempfile.TemporaryDirectory() as temporary: From f482ab99fc3cc6035d5f88aea0ad881742d250e6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:32:23 +0300 Subject: [PATCH 169/316] feat: complete the collector worker lifecycle Add prepare and finalize phases to the versioned collector contract with safe inherited defaults, strict static signatures, runtime contract checks, and isolated worker invocation around collection. Preserve artifact ownership across finalization, map malformed lifecycle results to contained collector failures, and verify that prepared state reaches a typed final result before cleanup. --- TODO.md | 2 +- logicytics/contracts.py | 8 ++++++++ logicytics/discovery.py | 16 +++++++++++++-- logicytics/runtime.py | 28 +++++++++++++++++++------ logicytics/validation_worker.py | 2 ++ tests/test_core_functionality.py | 35 ++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/TODO.md b/TODO.md index ab880f54..13bf61c3 100644 --- a/TODO.md +++ b/TODO.md @@ -388,7 +388,7 @@ Windows platform allows. configuration snapshot with secrets removed, host metadata, privilege state, start/end times, cancellation state, errors, skipped items, and artifact checksums. -- [ ] Give every collector an explicit lifecycle: validate, prepare, collect, +- [x] Give every collector an explicit lifecycle: validate, prepare, collect, finalize, report result. - [x] Make collector results explicit: `succeeded`, `partial`, `skipped`, `cancelled`, or `failed`. diff --git a/logicytics/contracts.py b/logicytics/contracts.py index e609ea1f..49db29a4 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -541,10 +541,18 @@ def metadata(cls) -> CollectorMetadata: def validate(self, context: CollectorContext) -> ValidationResult: """Validate prerequisites without creating evidence artifacts.""" + def prepare(self, context: CollectorContext) -> ValidationResult: + """Prepare collector-local resources after validation and authorization.""" + return ValidationResult(True) + @abstractmethod def collect(self, context: CollectorContext) -> CollectorResult: """Collect evidence and return a normalized result.""" + def finalize(self, context: CollectorContext, result: CollectorResult) -> CollectorResult: + """Finalize collector-local evidence and return the publishable result.""" + return result + def estimate(self, context: CollectorContext) -> CollectionEstimate: """Optionally estimate time and output without collecting evidence.""" return CollectionEstimate(estimated_seconds=0, estimated_output_bytes=0) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 3e9abb6e..e91b7c84 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -372,7 +372,9 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat for item in class_node.body if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_") } - allowed = {"metadata", "validate", "collect", "cleanup", "estimate", "dependencies"} + allowed = { + "metadata", "validate", "prepare", "collect", "finalize", "cleanup", "estimate", "dependencies", + } unknown = sorted(set(methods) - allowed) if unknown: candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") @@ -380,7 +382,9 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat expected_returns = { "metadata": "CollectorMetadata", "validate": "ValidationResult", + "prepare": "ValidationResult", "collect": "CollectorResult", + "finalize": "CollectorResult", "estimate": "CollectionEstimate", "dependencies": "tuple[str,...]", "cleanup": "None", @@ -396,7 +400,9 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat elif name in expected_returns and ast.unparse(method.returns).replace(" ", "") != expected_returns[name]: candidate.static_errors.append(f"{name} must return {expected_returns[name]}") parameters = method.args.args - expected_parameter_count = 1 if name in {"metadata", "dependencies"} else 2 + expected_parameter_count = ( + 1 if name in {"metadata", "dependencies"} else 3 if name == "finalize" else 2 + ) if len(parameters) != expected_parameter_count: candidate.static_errors.append(f"{name} has an invalid parameter count") continue @@ -413,6 +419,12 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat -1] if context_parameter.annotation else None if context_parameter.arg != "context" or annotation != "CollectorContext": candidate.static_errors.append(f"{name} must accept an annotated context parameter") + if name == "finalize": + result_parameter = parameters[2] + result_annotation = ast.unparse(result_parameter.annotation).rsplit(".", 1)[ + -1] if result_parameter.annotation else None + if result_parameter.arg != "result" or result_annotation != "CollectorResult": + candidate.static_errors.append("finalize must accept an annotated result parameter") def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: diff --git a/logicytics/runtime.py b/logicytics/runtime.py index b391c5fd..7db10a78 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -348,12 +348,28 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu elif context.is_cancelled: result = CollectorResult(CollectorStatus.CANCELLED, "cancelled before collection") else: - context.logger.event("info", "collection_started") - result = collector.collect(context) - if not isinstance(result, CollectorResult): - raise TypeError("collect() must return CollectorResult") - if result.artifacts != writer.artifacts: - raise TypeError("collector result artifacts must exactly match registered artifacts") + preparation = collector.prepare(context) + if not isinstance(preparation, ValidationResult): + raise TypeError("prepare() must return ValidationResult") + if not preparation.valid: + result = CollectorResult.skipped( + "collector preparation was not completed", + errors=preparation.reasons, + ) + elif context.is_cancelled: + result = CollectorResult.cancelled("cancelled after preparation") + else: + context.logger.event("info", "collection_started") + result = collector.collect(context) + if not isinstance(result, CollectorResult): + raise TypeError("collect() must return CollectorResult") + if result.artifacts != writer.artifacts: + raise TypeError("collector result artifacts must exactly match registered artifacts") + result = collector.finalize(context, result) + if not isinstance(result, CollectorResult): + raise TypeError("finalize() must return CollectorResult") + if result.artifacts != writer.artifacts: + raise TypeError("finalized result artifacts must exactly match registered artifacts") except BaseException as error: lifecycle_errors.extend((f"{type(error).__name__}: {error}", traceback.format_exc())) finally: diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index 8bbb5c4b..202256bd 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -113,7 +113,9 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> for method_name, expected_parameters in { "metadata": 0, "validate": 1, + "prepare": 1, "collect": 1, + "finalize": 2, "cleanup": 1, }.items(): method = getattr(collector_type, method_name, None) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index c368f911..70006909 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -3653,6 +3653,41 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: self.assertEqual("static.return_annotation", diagnostic.rule) self.assertEqual(str(collector_path), diagnostic.path) + def test_worker_runs_typed_prepare_collect_finalize_and_cleanup_lifecycle(self) -> None: + """Every accepted collector executes the complete typed lifecycle inside its worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + source = _COLLECTOR.replace( + " def collect(self, context: CollectorContext) -> CollectorResult:", + " def prepare(self, context: CollectorContext) -> ValidationResult:\n" + " \"\"\"Prepare worker-local lifecycle state.\"\"\"\n" + " context.settings['prepared'] = 'yes'\n" + " return ValidationResult(True)\n\n" + " def collect(self, context: CollectorContext) -> CollectorResult:", + ).replace( + " def cleanup(self, context: CollectorContext) -> None:", + " def finalize(self, context: CollectorContext, result: CollectorResult) -> CollectorResult:\n" + " \"\"\"Finalize the typed result before it crosses the worker boundary.\"\"\"\n" + " if context.settings.get('prepared') != 'yes':\n" + " raise RuntimeError('prepare phase did not run')\n" + " return CollectorResult.succeeded('finalized lifecycle', result.artifacts)\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ) + collector_path.write_text(source, encoding="utf-8") + + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + plan = build_plan( + report, + RunRequest(max_workers=1, acknowledge_authorization=True), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + self.assertEqual("finalized lifecycle", record.summary) + def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: """Optional estimates use the same strict typed contract as lifecycle methods.""" with tempfile.TemporaryDirectory() as temporary: From 758e4777adccb441f4fac589f704d9d45d2c0a4d Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:43:03 +0300 Subject: [PATCH 170/316] feat: require explicit plugin security metadata Expand immutable collector metadata with typed privilege, network reach, estimated cost, secondary category, and output media declarations. Require plugin source to state every consent, scheduling, timeout, size, output, and contract field instead of inheriting security-sensitive defaults; cross-check elevation and network capabilities; preserve schema-safe plugin specialty extensions; invalidate the older preflight cache schema; and verify the rules across the full test suite and shipped collector preflight. --- TODO.md | 9 ++--- core/network/network_identity.py | 2 ++ core/packet/packet_capture.py | 12 ++++++- logicytics/__init__.py | 6 ++++ logicytics/contracts.py | 58 +++++++++++++++++++++++++++++++ logicytics/discovery.py | 25 +++++++++++++- tests/test_core_functionality.py | 59 ++++++++++++++++++++++++++++---- 7 files changed, 159 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index 13bf61c3..d484cf4a 100644 --- a/TODO.md +++ b/TODO.md @@ -238,7 +238,7 @@ that fails any mandatory rule is not a collector and must not be launched. - [x] Require class metadata constants or an immutable metadata object for: `ID`, `NAME`, `VERSION`, `SPECIALTY`, `DESCRIPTION`, `AUTHOR`, and `SUPPORTED_PLATFORMS`. -- [ ] Require plugin metadata to additionally declare requested capabilities, +- [x] Require plugin metadata to additionally declare requested capabilities, privilege level, sensitive-data categories, network access, estimated cost, timeout, maximum output, and minimum contract version. - [x] Require IDs, names, specialties, and versions to match their schemas and @@ -246,12 +246,13 @@ that fails any mandatory rule is not a collector and must not be launched. #### Specialty rules -- [ ] Require `SPECIALTY` to be exactly one registered value, not an arbitrary free - text string. Initial values should cover system, hardware, process, memory, +- [x] Require `SPECIALTY` to be exactly one registered value: core collectors use + the closed built-in registry while plugins may register a schema-safe extension. + Initial values cover system, hardware, process, memory, filesystem, network, packet, wireless, Bluetooth, USB, browser, registry, event-log, storage, encryption, media, SSH, diagnostics, reporting, and integration. -- [ ] Require each collector to have one primary specialty. Related secondary +- [x] Require each collector to have one primary specialty. Related secondary categories may be declared as metadata but may not change the collector's identity. - [ ] Require the specialty to match the declared artifact types and the collector's diff --git a/core/network/network_identity.py b/core/network/network_identity.py index e33559e9..ad2d09b6 100644 --- a/core/network/network_identity.py +++ b/core/network/network_identity.py @@ -11,6 +11,7 @@ CollectorMetadata, CollectorResult, CoreCollector, + NetworkAccess, Specialty, ValidationResult, ) @@ -32,6 +33,7 @@ def metadata(cls) -> CollectorMetadata: author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.NETWORK,), + network_access=NetworkAccess.LOCAL, default_profiles=("standard", "deep"), timeout_seconds=10, maximum_output_bytes=64 * 1024, diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index 6b944f53..8dd4dd74 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -9,7 +9,16 @@ import select -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + NetworkAccess, + PrivilegeLevel, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus @@ -43,6 +52,7 @@ def metadata(cls) -> CollectorMetadata: description="Captures bounded IPv4 packet metadata without saving packet payloads.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.NETWORK, Capability.PACKET_CAPTURE, Capability.ELEVATED_PRIVILEGES), + privilege_level=PrivilegeLevel.ELEVATED, network_access=NetworkAccess.LOCAL, sensitive_data_categories=("network_metadata",), default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=2 * 1024 * 1024, ) diff --git a/logicytics/__init__.py b/logicytics/__init__.py index ae704169..7c22e4c6 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -9,9 +9,12 @@ CollectorResult, CoreCollector, EvidenceKind, + EstimatedCost, + NetworkAccess, OutputPolicy, PluginCollector, PostRunAction, + PrivilegeLevel, ResourceClass, RunRequest, RunStatus, @@ -45,9 +48,12 @@ def __getattr__(name: str): "CollectorSnapshot", "CoreCollector", "EvidenceKind", + "EstimatedCost", + "NetworkAccess", "OutputPolicy", "PluginCollector", "PostRunAction", + "PrivilegeLevel", "ResourceClass", "RunRequest", "RunSnapshot", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 49db29a4..b9e09612 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -122,6 +122,29 @@ class ResourceClass(StrEnum): INTERACTIVE = "interactive" +class PrivilegeLevel(StrEnum): + """Host privilege required before a collector may launch.""" + + STANDARD = "standard" + ELEVATED = "elevated" + + +class NetworkAccess(StrEnum): + """Declared network reach of a collector's implementation.""" + + NONE = "none" + LOCAL = "local" + REMOTE = "remote" + + +class EstimatedCost(StrEnum): + """Coarse scheduling and consent cost declared before execution.""" + + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + @dataclass(frozen=True, slots=True) class CollectorMetadata: """Declarative identity, limits, and permissions for one collector.""" @@ -132,6 +155,11 @@ class CollectorMetadata: specialty: Specialty | str description: str author: str + privilege_level: PrivilegeLevel = PrivilegeLevel.STANDARD + network_access: NetworkAccess = NetworkAccess.NONE + estimated_cost: EstimatedCost = EstimatedCost.LOW + secondary_categories: tuple[str, ...] = () + output_media_types: tuple[str, ...] = ("application/octet-stream",) supported_platforms: tuple[str, ...] = ("win32",) capabilities: tuple[Capability, ...] = () sensitive_data_categories: tuple[str, ...] = () @@ -165,12 +193,34 @@ def __post_init__(self) -> None: raise ValueError("metadata specialty has an invalid schema") self._validate_labels("supported_platforms", self.supported_platforms, require_value=True) self._validate_labels("sensitive_data_categories", self.sensitive_data_categories) + self._validate_labels("secondary_categories", self.secondary_categories) self._validate_labels("default_profiles", self.default_profiles, require_value=True) + primary_specialty = self.specialty.value if isinstance(self.specialty, Specialty) else self.specialty + if primary_specialty in self.secondary_categories: + raise ValueError("metadata secondary_categories must not repeat the primary specialty") + if not isinstance(self.output_media_types, tuple) or not self.output_media_types or not all( + isinstance(media_type, str) and _MEDIA_TYPE.fullmatch(media_type) + for media_type in self.output_media_types + ): + raise ValueError("metadata output_media_types must be a non-empty tuple of MIME types") + if len(set(self.output_media_types)) != len(self.output_media_types): + raise ValueError("metadata output_media_types must not contain duplicates") if self.sensitive_data_categories and {"standard", "minimal"}.intersection(self.default_profiles): raise ValueError("sensitive collectors must not belong to standard or minimal profiles") if not isinstance(self.capabilities, tuple) or not all( isinstance(capability, Capability) for capability in self.capabilities): raise ValueError("metadata capabilities must be a tuple of Capability values") + if not isinstance(self.privilege_level, PrivilegeLevel): + raise ValueError("metadata privilege_level must be a PrivilegeLevel value") + if not isinstance(self.network_access, NetworkAccess): + raise ValueError("metadata network_access must be a NetworkAccess value") + if not isinstance(self.estimated_cost, EstimatedCost): + raise ValueError("metadata estimated_cost must be an EstimatedCost value") + requires_elevation = Capability.ELEVATED_PRIVILEGES in self.capabilities + if requires_elevation != (self.privilege_level is PrivilegeLevel.ELEVATED): + raise ValueError("metadata privilege_level must match the elevated_privileges capability") + if Capability.NETWORK in self.capabilities and self.network_access is NetworkAccess.NONE: + raise ValueError("metadata network_access must declare local or remote access") if not isinstance(self.dependencies, tuple) or not all( isinstance(dependency, str) and _COLLECTOR_ID.fullmatch(dependency) for dependency in self.dependencies): @@ -220,6 +270,9 @@ def to_dict(self) -> dict[str, Any]: data["specialty"] = self.specialty.value if isinstance(self.specialty, Specialty) else self.specialty data["capabilities"] = [capability.value for capability in self.capabilities] data["resource_class"] = self.resource_class.value + data["privilege_level"] = self.privilege_level.value + data["network_access"] = self.network_access.value + data["estimated_cost"] = self.estimated_cost.value return data @classmethod @@ -236,9 +289,14 @@ def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = Fa values["specialty"] = specialty values["capabilities"] = tuple(Capability(capability) for capability in values.get("capabilities", ())) values["resource_class"] = ResourceClass(values.get("resource_class", ResourceClass.GENERAL)) + values["privilege_level"] = PrivilegeLevel(values.get("privilege_level", PrivilegeLevel.STANDARD)) + values["network_access"] = NetworkAccess(values.get("network_access", NetworkAccess.NONE)) + values["estimated_cost"] = EstimatedCost(values.get("estimated_cost", EstimatedCost.LOW)) for field_name in ( "supported_platforms", "sensitive_data_categories", + "secondary_categories", + "output_media_types", "dependencies", "default_profiles", ): diff --git a/logicytics/discovery.py b/logicytics/discovery.py index e91b7c84..363e21c5 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -22,7 +22,7 @@ "load_configuration", "manifest", "packaging", "plan_run", "planner", "query_run", "read_artifact", "runtime", "run_collection", "validation_worker", } -_CACHE_SCHEMA_VERSION = 1 +_CACHE_SCHEMA_VERSION = 2 @dataclass(frozen=True, slots=True) @@ -375,6 +375,29 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat allowed = { "metadata", "validate", "prepare", "collect", "finalize", "cleanup", "estimate", "dependencies", } + if candidate.kind is CollectorKind.PLUGIN and "metadata" in methods: + metadata_calls = [ + node for node in ast.walk(methods["metadata"]) + if isinstance(node, ast.Call) + and ( + isinstance(node.func, ast.Name) and node.func.id == "CollectorMetadata" + or isinstance(node.func, ast.Attribute) and node.func.attr == "CollectorMetadata" + ) + ] + required_plugin_fields = { + "capabilities", "privilege_level", "sensitive_data_categories", "network_access", + "estimated_cost", "timeout_seconds", "maximum_output_bytes", "output_media_types", + "minimum_contract_version", + } + if len(metadata_calls) != 1: + candidate.static_errors.append("plugin metadata must construct one CollectorMetadata object directly") + else: + declared_fields = {keyword.arg for keyword in metadata_calls[0].keywords if keyword.arg is not None} + missing_fields = sorted(required_plugin_fields - declared_fields) + if missing_fields: + candidate.static_errors.append( + f"plugin metadata must explicitly declare: {', '.join(missing_fields)}" + ) unknown = sorted(set(methods) - allowed) if unknown: candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 70006909..5078c73c 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -35,8 +35,11 @@ CollectorResult, CollectorStatus, EvidenceKind, + EstimatedCost, + NetworkAccess, OutputPolicy, PostRunAction, + PrivilegeLevel, ResourceClass, RunRequest, RunStatus, @@ -55,6 +58,7 @@ from pathlib import Path from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import EstimatedCost, NetworkAccess, PrivilegeLevel from logicytics.contracts import CollectorContext @@ -90,6 +94,23 @@ def cleanup(self, context: CollectorContext) -> None: ''' +def _plugin_collector_source() -> str: + """Convert the core fixture into a plugin with every security field explicit.""" + return _COLLECTOR.replace("CoreCollector", "PluginCollector").replace( + ' author="tests",', + ' author="tests",\n' + ' capabilities=(),\n' + ' privilege_level=PrivilegeLevel.STANDARD,\n' + ' sensitive_data_categories=(),\n' + ' network_access=NetworkAccess.NONE,\n' + ' estimated_cost=EstimatedCost.LOW,\n' + ' timeout_seconds=60,\n' + ' maximum_output_bytes=100 * 1024 * 1024,\n' + ' output_media_types=("text/plain",),\n' + ' minimum_contract_version="4.0",', + ) + + def _delayed_collector_source( filename: str, delay: float, @@ -136,6 +157,25 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(self) -> None: + """A plugin cannot silently inherit fields that affect consent or scheduling.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + plugin_path = root / "plugins" / "example_plugin.py" + plugin_path.parent.mkdir(parents=True) + source = _plugin_collector_source().replace("SystemInfoCollector", "ExamplePluginCollector") + source = source.replace("core.system.system_info", "plugin.example_plugin") + plugin_path.write_text( + source.replace(" network_access=NetworkAccess.NONE,\n", ""), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn( + "plugin metadata must explicitly declare: network_access", + report.invalid[0].static_errors, + ) + def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging(self) -> None: """Power actions remain explicit and cannot run before durable package publication.""" arguments = _parser().parse_args([ @@ -220,7 +260,7 @@ def test_public_planning_is_read_only_bounded_and_keeps_plugins_opt_in(self) -> plugin_path = root / "plugins" / "example_plugin.py" plugin_path.parent.mkdir() plugin_path.write_text( - _COLLECTOR.replace("CoreCollector", "PluginCollector") + _plugin_collector_source() .replace("SystemInfoCollector", "ExamplePluginCollector") .replace("core.system.system_info", "plugin.example_plugin"), encoding="utf-8", @@ -698,6 +738,7 @@ def test_offline_profile_rejects_network_collectors_even_with_explicit_approval( ' supported_platforms=("win32",),', ' supported_platforms=("win32",),\n' ' capabilities=(Capability.NETWORK,),\n' + ' network_access=NetworkAccess.LOCAL,\n' ' default_profiles=("standard",),', ) (core_directory / "network_source.py").write_text(source, encoding="utf-8") @@ -3269,7 +3310,9 @@ def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self "from pathlib import Path\nimport socket\nfrom logicytics import Capability\n", ).replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n capabilities=(Capability.NETWORK,),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.NETWORK,),\n' + ' network_access=NetworkAccess.LOCAL,', ).replace( ' output = context.workspace / "system.txt"', ' socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)\n' @@ -3941,7 +3984,8 @@ def test_elevated_collectors_require_approval_and_administrator_privileges(self) "from logicytics import Capability, CollectorMetadata", ).replace( 'supported_platforms=("win32",),', - 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,),', + 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,), ' + 'privilege_level=PrivilegeLevel.ELEVATED,', ) collector_path.write_text(secured_collector, encoding="utf-8") report = preflight(root) @@ -3967,15 +4011,18 @@ def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> Non root = Path(temporary) plugin_path = root / "plugins" / "admin_plugin.py" plugin_path.parent.mkdir(parents=True) - source = _COLLECTOR.replace("CoreCollector", "PluginCollector") + source = _plugin_collector_source() source = source.replace("SystemInfoCollector", "AdminPluginCollector") source = source.replace("core.system.system_info", "plugin.admin_plugin") source = source.replace( "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - 'supported_platforms=("win32",),', - 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,),', + " capabilities=(),", + " capabilities=(Capability.ELEVATED_PRIVILEGES,),", + ).replace( + " privilege_level=PrivilegeLevel.STANDARD,", + " privilege_level=PrivilegeLevel.ELEVATED,", ) plugin_path.write_text(source, encoding="utf-8") report = preflight(root) From ad7fb433b6c42c350ef7b7709cb9e1e77985d343 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:45:40 +0300 Subject: [PATCH 171/316] feat: make collection mode selection unambiguous Render full top-level help for an empty invocation, document every public selector and execution flag, and move performance analysis into the mutually exclusive collection-mode group. Preserve explicit sequential and parallel policy validation while rejecting contradictory legacy actions at argument parsing time, with CLI-level coverage for help and exclusivity behavior. --- TODO.md | 8 ++++---- logicytics/cli.py | 32 ++++++++++++++++++++++++-------- tests/test_core_functionality.py | 14 ++++++++++++++ 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index d484cf4a..338d066a 100644 --- a/TODO.md +++ b/TODO.md @@ -590,8 +590,8 @@ Windows platform allows. - [ ] `--modded`: run the normal collection set plus all supported files in MODS. - [x] `--performance-check`: run collectors sequentially, measure each duration, and write a performance summary table. -- [ ] Show help when no collection action is selected. -- [ ] Reject multiple mutually exclusive collection actions. +- [x] Show help when no collection action is selected. +- [x] Reject multiple mutually exclusive collection actions. ### Post-run actions @@ -601,11 +601,11 @@ Windows platform allows. ## 2. Command-line and interaction features -- [ ] Provide descriptive help text for every flag. +- [x] Provide descriptive help text for every flag. - [ ] Support the current action flags: `default`, `threaded`, `modded`, `depth`, `nopy`, `minimal`, `performance-check`, and `usage`. - [ ] Support side actions: `debug`, `update`, and `dev`. -- [ ] Provide action/sub-action exclusivity validation and clear invalid-combination +- [x] Provide action/sub-action exclusivity validation and clear invalid-combination errors. - [ ] Provide colored console output for status, warnings, errors, and prompts. - [ ] Provide a semantic flag-matching feature that can map natural-language user diff --git a/logicytics/cli.py b/logicytics/cli.py index d67a3d60..18d579a2 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -101,19 +101,31 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") - subcommands = parser.add_subparsers(dest="command", required=True) + subcommands = parser.add_subparsers(dest="command") for command in ("preflight", "debug", "update", "plan", "run"): - subparser = subcommands.add_parser(command) + subparser = subcommands.add_parser(command, help=f"Run the {command} action.") subparser.add_argument( "--profile", default="standard", choices=tuple(BUILTIN_PROFILES), help="Named built-in collector membership and access policy.", ) - subparser.add_argument("--include", action="append", default=[]) - subparser.add_argument("--exclude", action="append", default=[]) - subparser.add_argument("--plugins", action="store_true") - subparser.add_argument("--workers", type=int) + subparser.add_argument( + "--include", action="append", default=[], metavar="COLLECTOR_ID", + help="Include a collector by exact dotted ID; repeat for multiple collectors.", + ) + subparser.add_argument( + "--exclude", action="append", default=[], metavar="COLLECTOR_ID", + help="Exclude a collector by exact dotted ID; repeat for multiple collectors.", + ) + subparser.add_argument( + "--plugins", action="store_true", + help="Enable all valid opt-in plugin collectors for the selected profile.", + ) + subparser.add_argument( + "--workers", type=int, metavar="COUNT", + help="Bound concurrent isolated workers to this positive count.", + ) subparser.add_argument( "--allow-capability", action="append", @@ -145,7 +157,7 @@ def _parser() -> argparse.ArgumentParser: help="Run the standard built-in profile with configured parallel workers.") mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") - subparser.add_argument( + mode.add_argument( "--performance-check", action="store_true", help="Run serially and save per-collector duration measurements.", @@ -184,7 +196,11 @@ def _parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" - arguments = _parser().parse_args(argv) + parser = _parser() + arguments = parser.parse_args(argv) + if arguments.command is None: + parser.print_help() + return 0 root = _project_root() try: configuration = load_config(root, arguments.config) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 5078c73c..120e9064 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1265,6 +1265,20 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) + def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: + """An empty invocation is useful while contradictory legacy actions fail immediately.""" + output = io.StringIO() + with patch("sys.stdout", output): + self.assertEqual(0, main([])) + rendered = output.getvalue() + self.assertIn("Logicytics v4 run-oriented evidence framework", rendered) + self.assertIn("preflight", rendered) + self.assertIn("run", rendered) + with patch("sys.stderr", new_callable=io.StringIO) as errors: + with self.assertRaises(SystemExit): + _parser().parse_args(["run", "--default", "--performance-check"]) + self.assertIn("not allowed with argument", errors.getvalue()) + def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: """Execution policy is selectable directly instead of relying on compatibility modes.""" parser = _parser() From 563ba700390bad0d932202d1ed7bb41aa62aa681 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:49:41 +0300 Subject: [PATCH 172/316] feat: add local semantic action matching and usage analytics Match natural-language input against documented action names and descriptions with a configurable threshold and deterministic standard-library scoring, then fall back to stronger opt-in local history associations. Add strictly validated interaction settings, independently gated model diagnostics, atomic gzip history containing input, match, accuracy, timestamp, and device, per-flag usage statistics, a dependency-free SVG bar graph under output/data, standalone --match and --usage actions, and full regression coverage for disabled persistence, enabled persistence, history fallback, reporting, graph generation, and invalid configuration. --- TODO.md | 24 ++--- logicytics/cli.py | 38 ++++++++ logicytics/configuration.py | 44 ++++++++- logicytics/interaction.py | 159 +++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 80 ++++++++++++++++ 5 files changed, 331 insertions(+), 14 deletions(-) create mode 100644 logicytics/interaction.py diff --git a/TODO.md b/TODO.md index 338d066a..6cfc83cc 100644 --- a/TODO.md +++ b/TODO.md @@ -608,18 +608,18 @@ Windows platform allows. - [x] Provide action/sub-action exclusivity validation and clear invalid-combination errors. - [ ] Provide colored console output for status, warnings, errors, and prompts. -- [ ] Provide a semantic flag-matching feature that can map natural-language user +- [x] Provide a semantic flag-matching feature that can map natural-language user input to the closest command-line flag. -- [ ] Match input against both flag names and their descriptions. -- [ ] Use a configurable similarity threshold. -- [ ] Fall back to historical flag suggestions when direct matching is weak. -- [ ] Persist optional compressed interaction history locally only when enabled. -- [ ] Record matched flag, input, accuracy, timestamp, and device name in history. -- [ ] Track per-flag usage counts. -- [ ] Provide `--usage` statistics including total interactions, average accuracy, +- [x] Match input against both flag names and their descriptions. +- [x] Use a configurable similarity threshold. +- [x] Fall back to historical flag suggestions when direct matching is weak. +- [x] Persist optional compressed interaction history locally only when enabled. +- [x] Record matched flag, input, accuracy, timestamp, and device name in history. +- [x] Track per-flag usage counts. +- [x] Provide `--usage` statistics including total interactions, average accuracy, common device/input values, and per-flag frequency. -- [ ] Generate a flag-usage bar graph in the output/data area. -- [ ] Allow model debug/progress output to be enabled independently from normal +- [x] Generate a flag-usage bar graph in the output/data area. +- [x] Allow model debug/progress output to be enabled independently from normal Logicytics debug logging. ## 3. Core Logicytics engine/library API @@ -861,7 +861,7 @@ appropriate default/deep/non-Python mode. - [ ] Recreate general settings for debug logging, old-log deletion, worker count, and preference-history persistence. -- [ ] Recreate flag-matching settings for model name, minimum accuracy, and model +- [x] Recreate flag-matching settings for model name, minimum accuracy, and model debug output. - [ ] Recreate memory-dump settings for file-size limit, safety margin, and dump directory. @@ -905,7 +905,7 @@ appropriate default/deep/non-Python mode. - [ ] Implement sensitive-file mining, recursive directory/tree collection, and Sysinternals integration. - [ ] Implement packet capture, CSV export, and network graph generation. -- [ ] Implement flag-history statistics and usage graph generation. +- [x] Implement flag-history statistics and usage graph generation. ### Phase 5 — Maintenance and release readiness diff --git a/logicytics/cli.py b/logicytics/cli.py index 18d579a2..96e8771b 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -17,6 +17,7 @@ from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError from logicytics.manifest import MANIFEST_SCHEMA_VERSION +from logicytics.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -101,6 +102,8 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") + parser.add_argument("--usage", action="store_true", help="Show local interaction statistics and create a usage graph.") + parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") subcommands = parser.add_subparsers(dest="command") for command in ("preflight", "debug", "update", "plan", "run"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") @@ -198,12 +201,47 @@ def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" parser = _parser() arguments = parser.parse_args(argv) + if arguments.usage and arguments.match: + parser.error("--usage cannot be combined with --match") + if arguments.command is not None and (arguments.usage or arguments.match): + parser.error("--usage and --match are standalone actions") + if arguments.usage: + arguments.command = "usage" + elif arguments.match: + arguments.command = "match" if arguments.command is None: parser.print_help() return 0 root = _project_root() try: configuration = load_config(root, arguments.config) + history_path = configuration.runtime.output_root / "interaction_history.json.gz" + if arguments.command == "match": + history = load_history(history_path) + match = match_flag( + arguments.match, + threshold=configuration.interaction.similarity_threshold, + model_name=configuration.interaction.model_name, + history=history, + ) + if configuration.interaction.history_enabled: + record_match(history_path, match) + payload = { + "input": match.input, + "matched_flag": match.matched_flag, + "accuracy": match.accuracy, + "source": match.source, + "history_persisted": configuration.interaction.history_enabled, + } + if configuration.interaction.model_debug: + payload["model_debug"] = {"model_name": match.model_name, "threshold": configuration.interaction.similarity_threshold} + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 if match.matched_flag is not None else 1 + if arguments.command == "usage": + statistics = usage_statistics(load_history(history_path)) + graph_path = write_usage_graph(configuration.runtime.output_root / "flag_usage.svg", statistics) + print(json.dumps({**statistics, "graph_path": str(graph_path)}, indent=2, sort_keys=True)) + return 0 report = preflight(root, configuration_hash=configuration.fingerprint()) if arguments.command == "preflight": validation = report.to_dict( diff --git a/logicytics/configuration.py b/logicytics/configuration.py index a196259c..17b76392 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -19,10 +19,11 @@ MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 _COLLECTOR_ID = re.compile(r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|plugin\.[a-z][a-z0-9_]*)$") _SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") -_ROOT_FIELDS = frozenset({"schema_version", "runtime", "collectors"}) +_ROOT_FIELDS = frozenset({"schema_version", "runtime", "interaction", "collectors"}) _RUNTIME_FIELDS = frozenset({ "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", }) +_INTERACTION_FIELDS = frozenset({"history_enabled", "similarity_threshold", "model_name", "model_debug"}) _LEGACY_ROOT_FIELDS = _ROOT_FIELDS.union({ "collector_settings", "workers", "worker_count", "max_workers", "output_root", "package_completed_runs", "maximum_run_output_bytes", @@ -181,7 +182,12 @@ def _migrate_v3_configuration(raw: Mapping[str, Any], project_root: Path) -> dic relative_parts = tuple(part.casefold() for part in candidate.parts) if relative_parts == ("access", "runs") or candidate == project_root / "ACCESS" / "RUNS": runtime["output_root"] = str(project_root / "output" / "data") - return {"schema_version": SCHEMA_VERSION, "runtime": runtime, "collectors": collectors} + return { + "schema_version": SCHEMA_VERSION, + "runtime": runtime, + "interaction": raw.get("interaction", {}), + "collectors": collectors, + } @dataclass(frozen=True, slots=True) @@ -195,12 +201,23 @@ class RuntimeSettings: maximum_run_output_bytes: int = DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES +@dataclass(frozen=True, slots=True) +class InteractionSettings: + """Local-only matching, diagnostics, and optional history policy.""" + + history_enabled: bool = False + similarity_threshold: float = 0.55 + model_name: str = "stdlib-sequence-matcher" + model_debug: bool = False + + @dataclass(frozen=True, slots=True) class AppConfig: """Validated settings loaded from an optional JSON configuration file.""" schema_version: int runtime: RuntimeSettings + interaction: InteractionSettings = field(default_factory=InteractionSettings) collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) migrated_from_schema: int | None = None @@ -297,6 +314,23 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi ): raise PlanError("runtime maximum_run_output_bytes must be an integer from 1 to 68719476736") + interaction_raw = raw.get("interaction", {}) + if not isinstance(interaction_raw, dict): + raise PlanError("interaction configuration must be an object") + unknown_interaction = sorted(set(interaction_raw) - _INTERACTION_FIELDS) + if unknown_interaction: + raise PlanError(f"interaction configuration contains unsupported settings: {', '.join(unknown_interaction)}") + history_enabled = interaction_raw.get("history_enabled", False) + model_debug = interaction_raw.get("model_debug", False) + similarity_threshold = interaction_raw.get("similarity_threshold", 0.55) + model_name = interaction_raw.get("model_name", "stdlib-sequence-matcher") + if not isinstance(history_enabled, bool) or not isinstance(model_debug, bool): + raise PlanError("interaction history_enabled and model_debug must be boolean") + if not _bounded_number(similarity_threshold, minimum=0, maximum=1): + raise PlanError("interaction similarity_threshold must be a number from 0 to 1") + if not isinstance(model_name, str) or not model_name.strip() or any(char in model_name for char in "\r\n"): + raise PlanError("interaction model_name must be a non-empty single-line string") + collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( isinstance(key, str) and isinstance(value, dict) @@ -313,6 +347,12 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi package_completed_runs=package_completed_runs, maximum_run_output_bytes=maximum_run_output_bytes, ), + interaction=InteractionSettings( + history_enabled=history_enabled, + similarity_threshold=float(similarity_threshold), + model_name=model_name, + model_debug=model_debug, + ), collector_settings=collector_settings, migrated_from_schema=migrated_from_schema, ) diff --git a/logicytics/interaction.py b/logicytics/interaction.py new file mode 100644 index 00000000..3574c752 --- /dev/null +++ b/logicytics/interaction.py @@ -0,0 +1,159 @@ +"""Local semantic flag matching, opt-in history, and usage reporting.""" + +from __future__ import annotations + +import gzip +import json +import platform +import re +from collections import Counter +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from difflib import SequenceMatcher +from pathlib import Path +from typing import Iterable, Mapping + +FLAG_DESCRIPTIONS: Mapping[str, str] = { + "default": "standard collection sequentially", + "threaded": "standard collection with bounded parallel workers", + "minimal": "quick basic essential collection", + "depth": "deep exhaustive slow collection", + "nopy": "non Python PowerShell executable and batch collectors", + "modded": "normal collection plus opt in MODS extensions", + "performance-check": "sequential collector duration performance analysis", + "usage": "interaction statistics and flag usage graph", + "debug": "diagnostic environment configuration and integrity checks", + "update": "check or explicitly update the Git checkout", + "dev": "developer contribution manifest and version checks", +} +_TOKEN = re.compile(r"[a-z0-9]+") + + +@dataclass(frozen=True, slots=True) +class FlagMatch: + """One deterministic flag suggestion and its evidence.""" + + input: str + matched_flag: str | None + accuracy: float + source: str + model_name: str + + +def _normalized(value: str) -> str: + return " ".join(_TOKEN.findall(value.casefold())) + + +def _score(query: str, flag: str, description: str) -> float: + normalized_query = _normalized(query) + candidate = _normalized(f"{flag} {description}") + sequence = SequenceMatcher(None, normalized_query, candidate).ratio() + query_tokens = set(normalized_query.split()) + candidate_tokens = set(candidate.split()) + overlap = len(query_tokens.intersection(candidate_tokens)) / max(1, len(query_tokens)) + flag_score = SequenceMatcher(None, normalized_query, _normalized(flag)).ratio() + return round(max(sequence, overlap, flag_score), 6) + + +def match_flag( + user_input: str, + *, + threshold: float, + model_name: str, + history: Iterable[Mapping[str, object]] = (), +) -> FlagMatch: + """Match names and descriptions, then consult prior accepted inputs when weak.""" + if not isinstance(user_input, str) or not user_input.strip(): + raise ValueError("semantic flag input must be non-empty") + ranked = sorted( + ((_score(user_input, flag, description), flag) for flag, description in FLAG_DESCRIPTIONS.items()), + key=lambda item: (-item[0], item[1]), + ) + direct_score, direct_flag = ranked[0] + if direct_score >= threshold: + return FlagMatch(user_input, direct_flag, direct_score, "direct", model_name) + historical: list[tuple[float, str]] = [] + for item in history: + old_input = item.get("input") + old_flag = item.get("matched_flag") + if isinstance(old_input, str) and isinstance(old_flag, str) and old_flag in FLAG_DESCRIPTIONS: + historical.append((SequenceMatcher(None, _normalized(user_input), _normalized(old_input)).ratio(), old_flag)) + if historical: + history_score, history_flag = sorted(historical, key=lambda item: (-item[0], item[1]))[0] + if history_score > direct_score: + return FlagMatch(user_input, history_flag, round(history_score, 6), "history", model_name) + return FlagMatch(user_input, None, direct_score, "below_threshold", model_name) + + +def load_history(path: Path) -> list[dict[str, object]]: + """Read bounded compressed local history; malformed history fails closed.""" + if not path.exists(): + return [] + try: + with gzip.open(path, "rt", encoding="utf-8") as stream: + payload = json.load(stream) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + return [] + if not isinstance(payload, list): + return [] + return [dict(item) for item in payload[-10_000:] if isinstance(item, dict)] + + +def record_match(path: Path, match: FlagMatch) -> None: + """Atomically append one compressed, local-only interaction record.""" + history = load_history(path) + history.append({ + **asdict(match), + "timestamp": datetime.now(timezone.utc).isoformat(), + "device_name": platform.node() or "unknown", + }) + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + with gzip.open(temporary, "wt", encoding="utf-8") as stream: + json.dump(history[-10_000:], stream, sort_keys=True, separators=(",", ":")) + temporary.replace(path) + + +def usage_statistics(history: Iterable[Mapping[str, object]]) -> dict[str, object]: + """Aggregate total, accuracy, common values, and per-flag frequencies.""" + records = list(history) + accuracies = [float(item["accuracy"]) for item in records if isinstance(item.get("accuracy"), (int, float))] + flags = Counter(str(item["matched_flag"]) for item in records if isinstance(item.get("matched_flag"), str)) + devices = Counter(str(item["device_name"]) for item in records if isinstance(item.get("device_name"), str)) + inputs = Counter(str(item["input"]) for item in records if isinstance(item.get("input"), str)) + return { + "total_interactions": len(records), + "average_accuracy": round(sum(accuracies) / len(accuracies), 6) if accuracies else 0.0, + "common_device": devices.most_common(1)[0][0] if devices else None, + "common_input": inputs.most_common(1)[0][0] if inputs else None, + "per_flag_frequency": dict(sorted(flags.items())), + } + + +def write_usage_graph(path: Path, statistics: Mapping[str, object]) -> Path: + """Write a portable SVG bar graph without optional plotting dependencies.""" + raw_counts = statistics.get("per_flag_frequency", {}) + counts = dict(raw_counts) if isinstance(raw_counts, Mapping) else {} + labels = sorted(FLAG_DESCRIPTIONS) + width, row_height = 760, 30 + height = 70 + row_height * len(labels) + maximum = max((int(counts.get(label, 0)) for label in labels), default=0) or 1 + rows: list[str] = [] + for index, label in enumerate(labels): + count = int(counts.get(label, 0)) + y = 48 + index * row_height + bar_width = int(500 * count / maximum) + rows.extend(( + f'{label}', + f'', + f'{count}', + )) + svg = ( + f'' + 'Logicytics flag usage' + + "".join(rows) + "\n" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(svg, encoding="utf-8") + return path diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 120e9064..f4869166 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -25,6 +25,7 @@ from logicytics.cli import _parser, _request, main from logicytics.file_listing import list_files from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed +from logicytics.interaction import load_history, match_flag, usage_statistics from logicytics.manifest import write_manifest from logicytics.sysinternals import ensure_sysinternals from logicytics.configuration import default_config, load_config @@ -157,6 +158,85 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(self) -> None: + """Natural-language actions use configured matching and persist only with consent.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + self.assertEqual(0, main(["--match", "run a quick basic collection"])) + payload = json.loads(output.getvalue()) + self.assertEqual("minimal", payload["matched_flag"]) + self.assertFalse(payload["history_persisted"]) + self.assertFalse((root / "output" / "data" / "interaction_history.json.gz").exists()) + + (root / "logicytics.json").write_text( + json.dumps({ + "schema_version": 4, + "interaction": { + "history_enabled": True, + "similarity_threshold": 0.5, + "model_name": "stdlib-test-model", + "model_debug": True, + }, + }), + encoding="utf-8", + ) + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + self.assertEqual(0, main(["--match", "an exhaustive slow scan"])) + payload = json.loads(output.getvalue()) + self.assertEqual("depth", payload["matched_flag"]) + self.assertEqual("stdlib-test-model", payload["model_debug"]["model_name"]) + history_path = root / "output" / "data" / "interaction_history.json.gz" + history = load_history(history_path) + self.assertEqual(1, len(history)) + self.assertIn("timestamp", history[0]) + self.assertIn("device_name", history[0]) + + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + self.assertEqual(0, main(["--usage"])) + usage = json.loads(output.getvalue()) + self.assertEqual(1, usage["total_interactions"]) + self.assertEqual(1, usage["per_flag_frequency"]["depth"]) + graph_path = Path(usage["graph_path"]) + self.assertTrue(graph_path.exists()) + self.assertIn(" None: + """A weak direct match may reuse a stronger local historical association.""" + history = [{"input": "collect the strange moon report", "matched_flag": "debug"}] + result = match_flag( + "collect strange moon report", + threshold=0.99, + model_name="stdlib-sequence-matcher", + history=history, + ) + self.assertEqual("debug", result.matched_flag) + self.assertEqual("history", result.source) + statistics = usage_statistics([{**history[0], "accuracy": 0.9, "device_name": "host"}]) + self.assertEqual("collect the strange moon report", statistics["common_input"]) + + def test_interaction_configuration_rejects_unsafe_or_ambiguous_values(self) -> None: + """Matching and persistence policy must be typed before any history is opened.""" + invalid_sections = ( + {"similarity_threshold": 1.1}, + {"history_enabled": "yes"}, + {"model_debug": 1}, + {"model_name": "bad\nname"}, + {"unknown": True}, + ) + for section in invalid_sections: + with self.subTest(section=section), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "logicytics.json").write_text( + json.dumps({"schema_version": 4, "interaction": section}), + encoding="utf-8", + ) + with self.assertRaises(PlanError): + load_config(root) + def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(self) -> None: """A plugin cannot silently inherit fields that affect consent or scheduling.""" with tempfile.TemporaryDirectory() as temporary: From 3cc8f5bfd61f984f98facf8d10169b60a1ecb1ed Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 12:58:26 +0300 Subject: [PATCH 173/316] feat: add isolated legacy MODS compatibility execution Discover lowercase Python, PowerShell, batch, and executable mods recursively behind mandatory adjacent metadata sidecars and explicit opt-in selection. Extend immutable planning for modded and non-Python-only modes, quarantine malformed mods, execute each declared script from a private worker copy with bounded resources, minimal environment, structured LEVEL parsing, output MIME allowlisting, artifact registration, cancellation and failure containment, and real Windows adapter coverage. Publish mod evidence in a separately named, byte-verified ZIP with an atomic SHA-256 sidecar, document the complete extension contract and security boundary, and reconcile the now-proven engine lifecycle checklist. --- MODS.md | 83 +++++++++++++++++ TODO.md | 71 ++++++++------- logicytics/api.py | 2 +- logicytics/cli.py | 11 +++ logicytics/configuration.py | 4 +- logicytics/contracts.py | 10 +- logicytics/discovery.py | 77 +++++++++++++++- logicytics/packaging.py | 82 ++++++++++++++++- logicytics/planner.py | 13 +++ logicytics/runtime.py | 130 ++++++++++++++++++++++++++ tests/test_core_functionality.py | 151 +++++++++++++++++++++++++++++++ 11 files changed, 592 insertions(+), 42 deletions(-) create mode 100644 MODS.md diff --git a/MODS.md b/MODS.md new file mode 100644 index 00000000..a012c341 --- /dev/null +++ b/MODS.md @@ -0,0 +1,83 @@ +# MODS compatibility contract + +`MODS/` is the opt-in compatibility area for legacy Python, PowerShell, batch, +and Windows executable collectors. Normal runs never execute these files. +Use `run --modded` to run the standard core profile followed by valid mods, or +`run --nopy` to run only `.ps1`, `.bat`, and `.exe` mods. + +## Layout and discovery + +Mods are discovered recursively. Runnable filenames must be lowercase +`snake_case` and use `.py`, `.ps1`, `.bat`, or `.exe`. Files and directories +whose names begin with `_` are ignored. Every runnable file needs an adjacent +sidecar named `..mod.json`; for example: + +```text +MODS/ + inventory/ + local_report.ps1 + local_report.ps1.mod.json +``` + +The sidecar must declare the complete immutable collector metadata contract. +This example is intentionally bounded and non-sensitive: + +```json +{ + "id": "mod.local_report", + "name": "Local report", + "version": "1.0.0", + "specialty": "integration", + "description": "Creates a bounded local compatibility report.", + "author": "Example author", + "supported_platforms": ["win32"], + "capabilities": ["subprocess"], + "privilege_level": "standard", + "sensitive_data_categories": [], + "network_access": "none", + "estimated_cost": "low", + "timeout_seconds": 30, + "maximum_output_bytes": 1048576, + "maximum_artifact_files": 20, + "output_media_types": ["text/plain", "application/json"], + "minimum_contract_version": "4.0", + "default_profiles": ["standard"] +} +``` + +The ID must be `mod.`. Legacy adapters must request the +`subprocess` capability, and the user must approve it with +`--allow-capability subprocess`. Other access must also be declared and +approved. Invalid or missing sidecars quarantine the mod; selecting all mods +then fails preflight before any collector launches. + +## Execution and output + +Core collectors run before mods. Each mod receives its own worker process, +private workspace, temporary directory, event channel, timeout, memory limit, +artifact quota, cancellation signal, and result record. The source is copied +into the private workspace before execution. Python uses the configured running +interpreter, PowerShell uses a non-interactive execution-policy-bypass process, +batch uses `cmd.exe`, and executables launch directly without a command shell. + +Write generated evidence beneath the current working directory, exposed as +`LOGICYTICS_WORKSPACE`. Only non-empty files whose MIME type appears in +`output_media_types` are registered. Standard output and error are captured as +text artifacts when non-empty. Lines formatted as `LEVEL: message` are also +converted into structured events for `DEBUG`, `INFO`, `WARNING`, `ERROR`, +`CRITICAL`, `INTERNAL`, and `EXCEPTION`. + +A mod failure, crash, timeout, cancellation, or malformed output affects only +that mod. Independent collectors continue, and the terminal result appears in +the run manifest and summary. Registered mod evidence is included in the normal +manifest-led run package and in a separately named `mods-*.zip` with its own +SHA-256 sidecar. + +## Security boundary + +Mods are untrusted, opt-in local code. Worker/process isolation, a private +workspace, capability approval, bounded resources, packaging allowlists, and +process-tree termination contain engine state and evidence publication. They do +not grant access beyond the Windows account running Logicytics, nor do they +replace Windows ACLs or an AppContainer. Review a mod and its declared +capabilities before approving execution. diff --git a/TODO.md b/TODO.md index 6cfc83cc..a8ce2590 100644 --- a/TODO.md +++ b/TODO.md @@ -473,16 +473,16 @@ Windows platform allows. ### Improve the extension model - [x] Define a stable `Collector` interface and version it. -- [ ] Require every MODS collector to declare metadata rather than being discovered +- [x] Require every MODS collector to declare metadata rather than being discovered only by extension. -- [ ] Run mods in a sandboxed/isolated run workspace with declared inputs and +- [x] Run mods in a sandboxed/isolated run workspace with declared inputs and outputs. -- [ ] Give mods the same artifact writer, cancellation, timeout, logging, and +- [x] Give mods the same artifact writer, cancellation, timeout, logging, and result contract as built-in collectors. - [ ] Prevent a mod from changing global configuration, replacing built-in collectors, or writing outside its permitted workspace without explicit permission. -- [ ] Preserve compatibility with existing script mods through an adapter layer, +- [x] Preserve compatibility with existing script mods through an adapter layer, while making the typed collector API the v4.0 contract. ### Improve packaging and evidence integrity @@ -557,24 +557,25 @@ Windows platform allows. ## 1. Main engine and execution lifecycle - [ ] Provide a single main entry point that: - - [ ] Parses command-line flags and validates their combinations. - - [ ] Handles special actions before collection starts. - - [ ] Checks privileges and relevant Windows prerequisites. - - [ ] Extracts Sysinternals tools when permitted by configuration. - - [ ] Builds the execution list for the selected mode. - - [ ] Runs the selected Python, PowerShell, executable, and batch collectors. - - [ ] Captures collector output and converts script messages into Logicytics log + - [x] Parses command-line flags and validates their combinations. + - [x] Handles special actions before collection starts. + - [x] Checks privileges and relevant Windows prerequisites. + - [x] Extracts Sysinternals tools when permitted by configuration. + - [x] Builds the execution list for the selected mode. + - [x] Runs the selected Python, PowerShell, executable, and batch collectors. + - [x] Captures collector output and converts script messages into Logicytics log levels where applicable. - - [ ] Continues collecting when an individual collector fails, while recording + - [x] Continues collecting when an individual collector fails, while recording the failure. - - [ ] Packages generated evidence after collection. - - [ ] Produces a SHA-256 hash beside every package. - - [ ] Performs the requested shutdown or reboot action at the end. - - [ ] Handles Ctrl+C gracefully and attempts final packaging/cleanup. + - [x] Packages generated evidence after collection. + - [x] Produces a SHA-256 hash beside every package. + - [x] Performs the requested shutdown or reboot action at the end. + - [x] Handles Ctrl+C gracefully and attempts final packaging/cleanup. - [ ] Offers a final exit prompt for interactive use. -- [ ] Discover runnable files recursively from the CODE directory. -- [ ] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. -- [ ] Exclude engine/library directories and underscore-prefixed helper files from +- [x] Discover runnable files recursively from canonical `core/`, `plugins/`, and + `MODS/` directories; the legacy CODE directory is intentionally retired. +- [x] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. +- [x] Exclude engine/library directories and underscore-prefixed helper files from automatic collector discovery. - [x] Support a configurable worker limit for parallel collection. - [x] Provide per-collector success, failure, and duration information. @@ -586,8 +587,9 @@ Windows platform allows. - [x] `--minimal`: run the quick/basic collection set. - [x] `--depth`: run the standard set concurrently plus the slow, large-data collectors. -- [ ] `--nopy`: run the non-Python collectors for systems without Python. -- [ ] `--modded`: run the normal collection set plus all supported files in MODS. +- [x] `--nopy`: run only non-Python collector payloads; the v4 engine itself still + requires its configured Python runtime. +- [x] `--modded`: run the normal collection set plus all supported files in MODS. - [x] `--performance-check`: run collectors sequentially, measure each duration, and write a performance summary table. - [x] Show help when no collection action is selected. @@ -602,7 +604,7 @@ Windows platform allows. ## 2. Command-line and interaction features - [x] Provide descriptive help text for every flag. -- [ ] Support the current action flags: `default`, `threaded`, `modded`, `depth`, +- [x] Support the current action flags: `default`, `threaded`, `modded`, `depth`, `nopy`, `minimal`, `performance-check`, and `usage`. - [ ] Support side actions: `debug`, `update`, and `dev`. - [x] Provide action/sub-action exclusivity validation and clear invalid-combination @@ -627,9 +629,10 @@ Windows platform allows. ### Command execution - [x] Expose a reusable command runner that returns command stdout. -- [ ] Execute Python collectors through the configured Python runtime. -- [ ] Execute PowerShell collectors after unblocking them when necessary. -- [ ] Execute other supported script types through PowerShell where applicable. +- [x] Execute Python collectors through the configured Python runtime. +- [x] Execute PowerShell collectors from a private copy with execution-policy + handling that removes downloaded-file blocking from the execution path. +- [x] Execute batch and executable collector types through explicit shell-free adapters. - [x] Parse `LEVEL: message` output from non-Python scripts into structured logs. ### File discovery @@ -681,7 +684,7 @@ Windows platform allows. files, configuration secrets, caches, or library internals in the evidence package. - [ ] Include generated files from CODE in the package. -- [ ] Include MODS output in a separate MODS package during modded runs. +- [x] Include MODS output in a separate MODS package during modded runs. - [x] Preserve nested directory structure inside packages. - [x] Name packages using the action and timestamp. - [x] Generate a SHA-256 digest for each package and move both artifacts into the @@ -848,14 +851,14 @@ appropriate default/deep/non-Python mode. ## 6. MODS extension system -- [ ] Treat MODS as an opt-in extension directory. -- [ ] Discover `.py`, `.exe`, `.ps1`, and `.bat` mods. -- [ ] Run mods after the normal Logicytics collectors. -- [ ] Allow users to add custom collectors without changing the engine. -- [ ] Include mod-generated data in a separately named package. -- [ ] Document the mod contract, supported file types, execution order, output +- [x] Treat MODS as an opt-in extension directory. +- [x] Discover `.py`, `.exe`, `.ps1`, and `.bat` mods. +- [x] Run mods after the normal Logicytics collectors. +- [x] Allow users to add custom collectors without changing the engine. +- [x] Include mod-generated data in a separately named package. +- [x] Document the mod contract, supported file types, execution order, output location, logging convention, and failure behavior. -- [ ] Keep underscore-prefixed helper files out of automatic mod execution. +- [x] Keep underscore-prefixed helper files out of automatic mod execution. ## 7. Configuration and output contract @@ -891,7 +894,7 @@ appropriate default/deep/non-Python mode. performance reporting, and post-run actions. - [x] Implement ZIP creation, SHA-256 hashing, package naming, metadata, and output relocation. -- [ ] Implement the MODS discovery and packaging contract. +- [x] Implement the MODS discovery and packaging contract. ### Phase 3 — Core collectors diff --git a/logicytics/api.py b/logicytics/api.py index 2bb2b75f..c7b98865 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -21,7 +21,7 @@ _RUN_ID = re.compile(r"run-[0-9a-f]{32}") _ARTIFACT_ID = re.compile(r"artifact\.[0-9a-f]{32}") -_COLLECTOR_ID = re.compile(r"(?:core|plugin)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?") +_COLLECTOR_ID = re.compile(r"(?:core|plugin|mod)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?") _MAXIMUM_ARTIFACT_READ_BYTES = 64 * 1024 * 1024 _DEFAULT_ARTIFACT_READ_BYTES = 16 * 1024 * 1024 diff --git a/logicytics/cli.py b/logicytics/cli.py index 96e8771b..9f861873 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -35,6 +35,8 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: parallel = getattr(arguments, "parallel", False) performance_check = getattr(arguments, "performance_check", False) default_mode = getattr(arguments, "default_mode", False) + modded = getattr(arguments, "modded", False) + nopy = getattr(arguments, "nopy", False) if sequential and arguments.workers is not None and arguments.workers != 1: raise ValueError("sequential execution requires --workers=1") if parallel and (performance_check or default_mode): @@ -79,6 +81,8 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: include=tuple(arguments.include), exclude=tuple(arguments.exclude), enable_plugins=arguments.plugins, + enable_mods=getattr(arguments, "mods", False) or modded or nopy, + non_python_only=nopy, max_workers=worker_count, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), @@ -125,6 +129,10 @@ def _parser() -> argparse.ArgumentParser: "--plugins", action="store_true", help="Enable all valid opt-in plugin collectors for the selected profile.", ) + subparser.add_argument( + "--mods", action="store_true", + help="Enable valid sidecar-declared scripts from the MODS directory.", + ) subparser.add_argument( "--workers", type=int, metavar="COUNT", help="Bound concurrent isolated workers to this positive count.", @@ -160,6 +168,8 @@ def _parser() -> argparse.ArgumentParser: help="Run the standard built-in profile with configured parallel workers.") mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") + mode.add_argument("--modded", action="store_true", help="Run the standard profile plus all valid MODS scripts.") + mode.add_argument("--nopy", action="store_true", help="Run only non-Python PowerShell, batch, and executable MODS scripts.") mode.add_argument( "--performance-check", action="store_true", @@ -247,6 +257,7 @@ def main(argv: list[str] | None = None) -> int: validation = report.to_dict( selected_plugins=tuple(arguments.include), enable_plugins=arguments.plugins, + enable_mods=arguments.mods, ) payload = { "environment": inspect_environment().to_dict(), diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 17b76392..d43153ea 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -17,7 +17,9 @@ SCHEMA_VERSION = 4 DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 -_COLLECTOR_ID = re.compile(r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|plugin\.[a-z][a-z0-9_]*)$") +_COLLECTOR_ID = re.compile( + r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|(?:plugin|mod)\.[a-z][a-z0-9_]*)$" +) _SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") _ROOT_FIELDS = frozenset({"schema_version", "runtime", "interaction", "collectors"}) _RUNTIME_FIELDS = frozenset({ diff --git a/logicytics/contracts.py b/logicytics/contracts.py index b9e09612..b97f8a68 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -13,7 +13,7 @@ CONTRACT_VERSION = "4.0" _CUSTOM_SPECIALTY = re.compile(r"^[a-z][a-z0-9_]{1,63}$") -_COLLECTOR_ID = re.compile(r"^(?:core|plugin)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?$") +_COLLECTOR_ID = re.compile(r"^(?:core|plugin|mod)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?$") _SEMANTIC_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") _CONTRACT_VERSION = re.compile(r"^\d+\.\d+$") _LABEL = re.compile(r"^[a-z][a-z0-9_]{0,63}$") @@ -28,6 +28,7 @@ class CollectorKind(StrEnum): CORE = "core" PLUGIN = "plugin" + MOD = "mod" class CollectorStatus(StrEnum): @@ -481,6 +482,8 @@ class RunRequest: include: tuple[str, ...] = () exclude: tuple[str, ...] = () enable_plugins: bool = False + enable_mods: bool = False + non_python_only: bool = False max_workers: int = 4 acknowledge_authorization: bool = False approved_capabilities: tuple[Capability, ...] = () @@ -510,7 +513,10 @@ def __post_init__(self) -> None: raise ValueError("request rerun_from must be a valid original run ID") if self.rerun_from is not None and not self.include: raise ValueError("request rerun_from requires explicit included collector IDs") - for name in ("enable_plugins", "acknowledge_authorization", "performance_check"): + for name in ( + "enable_plugins", "enable_mods", "non_python_only", + "acknowledge_authorization", "performance_check", + ): if not isinstance(getattr(self, name), bool): raise ValueError(f"request {name} must be boolean") if not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or not 1 <= self.max_workers <= 64: diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 363e21c5..6b6bc15a 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -13,9 +13,11 @@ from dataclasses import asdict, dataclass, field from pathlib import Path -from logicytics.contracts import CONTRACT_VERSION, CollectorKind, CollectorMetadata +from logicytics.contracts import CONTRACT_VERSION, Capability, CollectorKind, CollectorMetadata _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") +_MOD_FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.(?:py|ps1|bat|exe)$") +_MOD_EXTENSIONS = {".py": "mod_python", ".ps1": "mod_powershell", ".bat": "mod_batch", ".exe": "mod_executable"} _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} _APPLICATION_IMPORTS = { "CollectorSnapshot", "RunSnapshot", "api", "artifacts", "cli", "configuration", "discovery", "environment", @@ -45,6 +47,7 @@ class CollectorCandidate: static_errors: list[str] = field(default_factory=list) metadata: CollectorMetadata | None = None runtime_error: str | None = None + execution_type: str = "collector" @property def valid(self) -> bool: @@ -95,6 +98,7 @@ def to_dict( *, selected_plugins: tuple[str, ...] = (), enable_plugins: bool = False, + enable_mods: bool = False, ) -> dict[str, list[dict[str, object]]]: """Classify invalid plugins as quarantined unless the request selects them.""" valid = [ @@ -112,7 +116,12 @@ def to_dict( "path": str(candidate.path), "diagnostics": [asdict(diagnostic) for diagnostic in candidate.diagnostics], } - if candidate.kind is CollectorKind.CORE or enable_plugins or candidate.selection_id in selected: + if ( + candidate.kind is CollectorKind.CORE + or (enable_plugins and candidate.kind is CollectorKind.PLUGIN) + or (enable_mods and candidate.kind is CollectorKind.MOD) + or candidate.selection_id in selected + ): invalid.append(item) else: quarantined.append(item) @@ -363,6 +372,56 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: return candidate +def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: + """Validate one legacy script mod and its mandatory immutable metadata sidecar.""" + candidate = CollectorCandidate( + path=path, + kind=CollectorKind.MOD, + expected_class="", + execution_type=_MOD_EXTENSIONS.get(path.suffix.casefold(), "unsupported"), + ) + if not _is_within(path, mods_root) or path.is_symlink(): + candidate.static_errors.append("mod path must remain a regular file inside MODS") + return candidate + if not _MOD_FILENAME.fullmatch(path.name): + candidate.static_errors.append("mod filename must be lowercase snake_case with a supported extension") + sidecar = path.with_suffix(path.suffix + ".mod.json") + if not sidecar.is_file() or sidecar.is_symlink() or not _is_within(sidecar, mods_root): + candidate.static_errors.append(f"mod requires metadata sidecar {sidecar.name}") + return candidate + try: + raw = json.loads(sidecar.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + candidate.static_errors.append(f"cannot parse mod metadata sidecar: {error}") + return candidate + if not isinstance(raw, dict): + candidate.static_errors.append("mod metadata sidecar must contain an object") + return candidate + required = { + "id", "name", "version", "specialty", "description", "author", "supported_platforms", + "capabilities", "privilege_level", "sensitive_data_categories", "network_access", + "estimated_cost", "timeout_seconds", "maximum_output_bytes", "maximum_artifact_files", + "output_media_types", "minimum_contract_version", "default_profiles", + } + missing = sorted(required - set(raw)) + if missing: + candidate.static_errors.append(f"mod metadata must explicitly declare: {', '.join(missing)}") + return candidate + try: + metadata = CollectorMetadata.from_dict(raw, allow_custom_specialty=True) + except (KeyError, TypeError, ValueError) as error: + candidate.runtime_error = f"invalid mod metadata: {error}" + return candidate + expected_id = f"mod.{path.stem}" + if metadata.id != expected_id: + candidate.runtime_error = f"mod ID must match its filename: {expected_id}" + elif Capability.SUBPROCESS not in metadata.capabilities: + candidate.runtime_error = "legacy script mods must declare the subprocess capability" + else: + candidate.metadata = metadata + return candidate + + def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidate) -> None: """Enforce the small, documented public API allowed on a collector class.""" if ast.get_docstring(class_node) is None: @@ -460,6 +519,14 @@ def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: _validate_static(path, CollectorKind.PLUGIN) for path in _iter_candidates(project_root, CollectorKind.PLUGIN) ) + mods_root = project_root / "MODS" + if mods_root.exists(): + for path in sorted(item for item in mods_root.rglob("*") if item.is_file()): + relative = path.relative_to(mods_root) + if any(part.startswith("_") for part in relative.parts): + continue + if path.suffix.casefold() in _MOD_EXTENSIONS: + candidates.append(_validate_mod(path, mods_root)) return tuple(candidates) @@ -470,7 +537,7 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> raise ValueError("metadata payload must be an object") metadata = CollectorMetadata.from_dict( payload, - allow_custom_specialty=candidate.kind is CollectorKind.PLUGIN, + allow_custom_specialty=candidate.kind is not CollectorKind.CORE, ) except (KeyError, TypeError, ValueError) as error: candidate.runtime_error = f"invalid validation response: {error}" @@ -491,6 +558,8 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" ): candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" + elif candidate.kind is CollectorKind.MOD and metadata.id != f"mod.{candidate.path.stem}": + candidate.runtime_error = "mod collector ID must match its script filename" else: candidate.metadata = metadata @@ -607,6 +676,8 @@ def preflight(project_root: Path, *, configuration_hash: str = "unconfigured") - for candidate in candidates: if candidate.static_errors: continue + if candidate.kind is CollectorKind.MOD and candidate.metadata is not None: + continue relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() entry = cached.get(relative_path) if ( diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 5d2d7a9f..1a4967eb 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -224,6 +224,84 @@ def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: s raise ValueError(f"packaged artifact verification failed: {artifact.relative_path}") +def _package_mod_artifacts( + package_directory: Path, + hash_directory: Path, + manifest: RunManifest, + artifact_sources: list[tuple[Path, str]], +) -> dict[str, str]: + """Publish MODS evidence in a separately named atomic package and sidecar.""" + mod_sources = [ + (source, archive_name) + for source, archive_name in artifact_sources + if any( + artifact.collector_id.startswith("mod.") and _artifact_archive_name(artifact) == archive_name + for artifact in manifest.artifact_list() + ) + ] + if not mod_sources: + return {} + package_path = package_directory / f"mods-{_package_filename(manifest)}" + hash_path = hash_directory / f"{package_path.name}.sha256" + temporary_package = package_path.with_suffix(".zip.tmp") + temporary_hash = hash_path.with_suffix(".sha256.tmp") + mod_catalog = { + "run_id": manifest.run_id, + "artifacts": [ + item for item in manifest.artifact_catalog() + if str(item.get("collector_id", "")).startswith("mod.") + ], + } + expected_names = {"metadata/mods.json", *(archive_name for _, archive_name in mod_sources)} + try: + with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("metadata/mods.json", json.dumps(mod_catalog, indent=2, sort_keys=True) + "\n") + for source, archive_name in mod_sources: + _stream_archive_member(archive, source, archive_name) + with zipfile.ZipFile(temporary_package) as archive: + if set(archive.namelist()) != expected_names or archive.testzip() is not None: + raise ValueError("MODS package contents failed verification") + for artifact in manifest.artifact_list(): + if not artifact.collector_id.startswith("mod."): + continue + member = archive.getinfo(_artifact_archive_name(artifact)) + with archive.open(member) as stream: + digest = _sha256_stream(stream) + if member.file_size != artifact.size_bytes or digest != artifact.sha256: + raise ValueError(f"MODS artifact verification failed: {artifact.relative_path}") + digest = sha256_file(temporary_package) + temporary_hash.write_text(f"{digest} {package_path.name}\n", encoding="ascii") + package_backup = package_path.with_suffix(".zip.backup") + hash_backup = hash_path.with_suffix(".sha256.backup") + try: + if package_path.exists(): + os.replace(package_path, package_backup) + if hash_path.exists(): + os.replace(hash_path, hash_backup) + os.replace(temporary_package, package_path) + os.replace(temporary_hash, hash_path) + except BaseException: + if package_path.exists(): + package_path.unlink() + if hash_path.exists() and hash_backup.exists(): + hash_path.unlink() + if package_backup.exists(): + os.replace(package_backup, package_path) + if hash_backup.exists(): + os.replace(hash_backup, hash_path) + raise + if package_backup.exists(): + package_backup.unlink() + if hash_backup.exists(): + hash_backup.unlink() + finally: + if temporary_package.exists(): + temporary_package.unlink() + if temporary_hash.exists(): + temporary_hash.unlink() + return {"mods_path": str(package_path), "mods_sha256_path": str(hash_path), "mods_sha256": digest} + + def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: Path) -> tuple[Path, Path]: """Package registered artifacts, manifest, and summary without scanning arbitrary files.""" package_directory = run_directory / "packages" @@ -237,7 +315,8 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: artifact_hash_path = hash_directory / "artifacts.sha256" artifact_sources = _artifact_sources(run_directory, manifest) log_sources = _log_sources(run_directory, manifest) - manifest.package = {"path": str(package_path)} + mods_package = _package_mod_artifacts(package_directory, hash_directory, manifest, artifact_sources) + manifest.package = {"path": str(package_path), **mods_package} write_manifest(manifest_path, manifest) _write_text_atomic(summary_path, _summary(manifest), encoding="utf-8") _write_text_atomic(artifact_hash_path, _artifact_checksum_catalog(manifest), encoding="ascii") @@ -288,6 +367,7 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: "path": str(package_path), "sha256_path": str(hash_path), "sha256": package_sha256, + **mods_package, } write_manifest(manifest_path, manifest) return package_path, hash_path diff --git a/logicytics/planner.py b/logicytics/planner.py index 17368985..9b9e87a6 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -53,6 +53,14 @@ def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> return False if candidate.kind is CollectorKind.PLUGIN and not request.enable_plugins: return False + if candidate.kind is CollectorKind.MOD: + if not request.enable_mods: + return False + if request.non_python_only and candidate.execution_type == "mod_python": + return False + return True + if request.non_python_only: + return False return request.profile in metadata.default_profiles @@ -96,6 +104,7 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if candidate.kind is CollectorKind.CORE or candidate.selection_id in request.include or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) + or (request.enable_mods and candidate.kind is CollectorKind.MOD) ] if invalid_selected: details = "; ".join( @@ -127,6 +136,10 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: request.enable_plugins or dependency_id in request.include ): raise PlanError(f"plugin dependency {dependency_id} must be explicitly selected or plugins enabled") + if dependency.kind is CollectorKind.MOD and not ( + request.enable_mods or dependency_id in request.include + ): + raise PlanError(f"mod dependency {dependency_id} must be explicitly selected or mods enabled") if dependency.metadata.sensitive_data_categories and dependency_id not in request.include: raise PlanError(f"sensitive dependency {dependency_id} must be explicitly selected") if dependency_id not in selected: diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 7db10a78..9b37fe91 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -6,6 +6,7 @@ import ctypes import importlib.util import json +import mimetypes import multiprocessing import os import queue @@ -25,10 +26,12 @@ from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.configuration import AppConfig +from logicytics.command_runner import parse_level_messages from logicytics.contracts import ( Artifact, Capability, CollectorContext, + CollectorMetadata, CollectorResult, CollectorStatus, OutputPolicy, @@ -280,6 +283,119 @@ def _result_from_dict(data: dict[str, object]) -> CollectorResult: ) +def _mod_command(script: Path, execution_type: str) -> list[str]: + """Build a shell-free command for one copied legacy MODS script.""" + if execution_type == "mod_python": + return [sys.executable, str(script)] + if execution_type == "mod_powershell": + return ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)] + if execution_type == "mod_batch": + return ["cmd.exe", "/d", "/c", str(script)] + if execution_type == "mod_executable": + return [str(script)] + raise ValueError(f"unsupported mod execution type: {execution_type}") + + +def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Queue) -> None: + """Adapt a sidecar-declared legacy script to the isolated collector contract.""" + metadata = CollectorMetadata.from_dict(dict(payload["metadata"]), allow_custom_specialty=True) + workspace = Path(str(payload["workspace"])) + artifact_root = Path(str(payload["artifact_root"])) + cancellation_file = Path(str(payload["cancellation_file"])) + source_directory = workspace / "source" + source_directory.mkdir(parents=True, exist_ok=True) + source = Path(str(payload["path"])) + copied_script = source_directory / source.name + shutil.copy2(source, copied_script) + writer = WorkspaceArtifactWriter( + metadata.id, + workspace, + artifact_root, + metadata.maximum_output_bytes, + metadata.maximum_artifact_files, + source_category=metadata.specialty.value if hasattr(metadata.specialty, "value") else metadata.specialty, + maximum_artifact_bytes=metadata.maximum_artifact_bytes, + run_output_budget_bytes=int(payload["run_output_budget_bytes"]), + cancellation_file=cancellation_file, + ) + logger = FileEventLogger( + workspace / "events.jsonl", + run_id=str(payload["run_id"]), + collector_id=metadata.id, + ) + stdout_path = workspace / "script_stdout.txt" + stderr_path = workspace / "script_stderr.txt" + guard = _WorkerMutationGuard(workspace, artifact_root, metadata.id, metadata.capabilities, source) + result: CollectorResult + try: + if cancellation_file.exists(): + result = CollectorResult.cancelled("mod cancelled before execution") + else: + command = _mod_command(copied_script, str(payload["execution_type"])) + environment = { + "PATH": os.environ.get("PATH", ""), + "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), + "WINDIR": os.environ.get("WINDIR", ""), + "COMSPEC": os.environ.get("COMSPEC", ""), + "TEMP": str(workspace / "tmp"), + "TMP": str(workspace / "tmp"), + "LOGICYTICS_RUN_ID": str(payload["run_id"]), + "LOGICYTICS_COLLECTOR_ID": metadata.id, + "LOGICYTICS_WORKSPACE": str(workspace), + } + guard.active = True + try: + completed = subprocess.run( + command, + cwd=workspace, + env=environment, + capture_output=True, + check=False, + text=True, + ) + finally: + guard.active = False + stdout_path.write_text(completed.stdout, encoding="utf-8") + stderr_path.write_text(completed.stderr, encoding="utf-8") + for level, message in parse_level_messages(completed.stdout): + logger.event(level.casefold(), message) + excluded_roots = {source_directory.resolve(), (workspace / "tmp").resolve()} + excluded_files = {stdout_path.resolve(), stderr_path.resolve(), (workspace / "events.jsonl").resolve()} + candidates = [stdout_path, stderr_path] + candidates.extend( + path for path in sorted(workspace.rglob("*")) + if path.is_file() + and path.resolve() not in excluded_files + and not any(path.resolve().is_relative_to(root) for root in excluded_roots) + ) + artifacts: list[Artifact] = [] + for path in candidates: + if not path.exists() or path.stat().st_size == 0: + continue + media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + if media_type not in metadata.output_media_types: + raise ValueError(f"mod output media type was not declared: {media_type} ({path.name})") + artifacts.append(writer.register_file(path, media_type=media_type)) + if cancellation_file.exists(): + result = CollectorResult.cancelled("mod cancelled after execution", tuple(artifacts)) + elif completed.returncode == 0: + result = CollectorResult.succeeded("legacy mod completed", tuple(artifacts)) + else: + result = CollectorResult.failed( + "legacy mod exited unsuccessfully", + errors=(f"exit code {completed.returncode}", completed.stderr.strip() or "no stderr"), + artifacts=tuple(artifacts), + ) + except BaseException as error: + guard.active = False + result = CollectorResult.failed( + "legacy mod worker crashed", + errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), + artifacts=writer.artifacts, + ) + result_queue.put({"collector_id": metadata.id, "result": _serialize_result(result)}) + + def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queue) -> None: """Run a single collector in an isolated child process.""" if os.name != "nt": @@ -288,6 +404,18 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu workspace.mkdir(parents=True, exist_ok=True) temporary_directory = workspace / "tmp" temporary_directory.mkdir(exist_ok=True) + if str(payload.get("execution_type", "collector")) != "collector": + try: + _run_mod_worker(payload, result_queue) + except BaseException as error: + result_queue.put({ + "collector_id": str(payload["collector_id"]), + "result": _serialize_result(CollectorResult.failed( + "legacy mod worker crashed", + errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), + )), + }) + return stdout_path = workspace / "stdout.log" stderr_path = workspace / "stderr.log" try: @@ -693,6 +821,8 @@ def _supervise( "collector_id": candidate.metadata.id, "path": str(candidate.path), "expected_class": candidate.expected_class, + "execution_type": candidate.execution_type, + "metadata": candidate.metadata.to_dict(), "workspace": str(workspace), "artifact_root": str(artifact_root), "cancellation_file": str(cancellation_file), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index f4869166..663a17c7 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -7,6 +7,7 @@ import io import json import os +import shutil import subprocess import sys import tempfile @@ -112,6 +113,30 @@ def _plugin_collector_source() -> str: ) +def _mod_metadata(name: str) -> dict[str, object]: + """Return a complete sidecar declaration for a harmless legacy script fixture.""" + return { + "id": f"mod.{name}", + "name": f"{name} mod", + "version": "1.0.0", + "specialty": "integration", + "description": "Harmless isolated legacy script fixture.", + "author": "tests", + "supported_platforms": [sys.platform], + "capabilities": ["subprocess"], + "privilege_level": "standard", + "sensitive_data_categories": [], + "network_access": "none", + "estimated_cost": "low", + "timeout_seconds": 15, + "maximum_output_bytes": 1024 * 1024, + "maximum_artifact_files": 10, + "output_media_types": ["text/plain"], + "minimum_contract_version": "4.0", + "default_profiles": ["standard"], + } + + def _delayed_collector_source( filename: str, delay: float, @@ -158,6 +183,132 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> None: + """Legacy scripts enter the pipeline only through typed metadata and worker isolation.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + script = mods / "example.py" + script.write_text( + "from pathlib import Path\n" + "Path('report.txt').write_text('mod evidence\\n', encoding='utf-8')\n" + "print('INFO: fixture completed')\n", + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("requires metadata sidecar", report.invalid[0].static_errors[0]) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest(enable_mods=True)) + + script.with_suffix(".py.mod.json").write_text( + json.dumps(_mod_metadata("example")), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + self.assertEqual((), build_plan(report, RunRequest()).collectors) + plan = build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=(Capability.SUBPROCESS,), + acknowledge_authorization=True, + max_workers=1, + ), + ) + self.assertEqual(["mod.example"], [item.metadata.id for item in plan.collectors]) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + self.assertEqual("succeeded", record.status, record.errors) + self.assertEqual(2, len(record.artifacts)) + report_artifact = next(item for item in record.artifacts if item["name"] == "report.txt") + artifact_path = outcome.run_directory / "artifacts" / str(report_artifact["relative_path"]) + self.assertEqual("mod evidence\n", artifact_path.read_text(encoding="utf-8")) + mods_package = Path(str(outcome.manifest.package["mods_path"])) + mods_hash = Path(str(outcome.manifest.package["mods_sha256_path"])) + self.assertTrue(mods_package.name.startswith("mods-run-")) + self.assertTrue(mods_hash.is_file()) + with zipfile.ZipFile(mods_package) as archive: + names = archive.namelist() + self.assertIn("metadata/mods.json", names) + self.assertTrue(any(name.endswith("/report.txt") for name in names)) + self.assertIsNone(archive.testzip()) + + def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers(self) -> None: + """Compatibility modes include all MODS or only non-Python MODS deterministically.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + for name, extension in (("python_mod", ".py"), ("batch_mod", ".bat")): + script = mods / f"{name}{extension}" + script.write_text("pass\n" if extension == ".py" else "@echo off\n", encoding="utf-8") + script.with_suffix(extension + ".mod.json").write_text( + json.dumps(_mod_metadata(name)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(2, len(report.valid), report.invalid) + modded = build_plan(report, RunRequest(enable_mods=True, approved_capabilities=(Capability.SUBPROCESS,))) + nopy = build_plan(report, RunRequest( + enable_mods=True, + non_python_only=True, + approved_capabilities=(Capability.SUBPROCESS,), + )) + self.assertEqual(["mod.batch_mod", "mod.python_mod"], [item.metadata.id for item in modded.collectors]) + self.assertEqual(["mod.batch_mod"], [item.metadata.id for item in nopy.collectors]) + parser = _parser() + self.assertTrue(_request(parser.parse_args(["run", "--modded"]), 2).enable_mods) + nopy_request = _request(parser.parse_args(["run", "--nopy"]), 2) + self.assertTrue(nopy_request.enable_mods) + self.assertTrue(nopy_request.non_python_only) + + @unittest.skipUnless(os.name == "nt", "legacy script adapters require Windows") + def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files(self) -> None: + """Every documented non-Python MODS type executes through its explicit adapter.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + scripts = { + "powershell_mod.ps1": "Set-Content -LiteralPath report.txt -Value 'powershell evidence'\n", + "batch_mod.bat": "@echo off\r\necho batch evidence>report.txt\r\n", + } + for filename, contents in scripts.items(): + script = mods / filename + script.write_text(contents, encoding="utf-8") + script.with_suffix(script.suffix + ".mod.json").write_text( + json.dumps(_mod_metadata(script.stem)), + encoding="utf-8", + ) + executable = Path(os.environ.get("WINDIR", r"C:\Windows")) / "System32" / "whoami.exe" + self.assertTrue(executable.is_file()) + copied_executable = mods / "identity_mod.exe" + shutil.copy2(executable, copied_executable) + copied_executable.with_suffix(".exe.mod.json").write_text( + json.dumps(_mod_metadata("identity_mod")), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(3, len(report.valid), report.invalid) + plan = build_plan( + report, + RunRequest( + enable_mods=True, + non_python_only=True, + approved_capabilities=(Capability.SUBPROCESS,), + acknowledge_authorization=True, + max_workers=1, + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual( + ["succeeded", "succeeded", "succeeded"], + [record.status for record in outcome.manifest.collectors], + [record.errors for record in outcome.manifest.collectors], + ) def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(self) -> None: """Natural-language actions use configured matching and persist only with consent.""" with tempfile.TemporaryDirectory() as temporary: From 6f200225ef813978bbec0e4ac04f91879acd0a10 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 13:11:19 +0300 Subject: [PATCH 174/316] feat: add authenticated maintenance diagnostics Introduce strict local and pinned HTTPS integrity manifests with deterministic file comparison, semantic prerelease handling, Python support classification, and repository organization checks. Add the isolated dev workflow and expand debug diagnostics with a dedicated JSON report while keeping remote data outside collection planning and preventing manifests from defining collectors. Document the maintenance configuration and commands, add regression coverage for authentication and manifest lifecycle behavior, and mark the verified TODO requirements complete. --- README.md | 21 ++ TODO.md | 34 +-- logicytics/cli.py | 112 +++++++++- logicytics/configuration.py | 80 ++++++- logicytics/maintenance.py | 354 +++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 161 +++++++++++++- 6 files changed, 739 insertions(+), 23 deletions(-) create mode 100644 logicytics/maintenance.py diff --git a/README.md b/README.md index d19b2348..6c20af2b 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ Run these from the repository root: ```powershell python -m logicytics preflight +python -m logicytics debug +python -m logicytics dev python -m logicytics plan --profile standard python -m logicytics run --profile standard --acknowledge-authorization ``` @@ -118,6 +120,13 @@ ID, and are written into that run's `packages/` and `hashes/` directories; collector workspaces and `logs/` remain run-scoped. Explicit reruns use a `rerun--run-.zip` package identity. +`debug` writes redacted environment, preflight, Sysinternals, Python-support, +version, and configured file-integrity diagnostics to +`output/logs/debug/debug.json`. `dev` compares the repository with the optional +integrity manifest and checks source organization without running collectors. +Manifest changes are opt-in: use `dev --write-manifest --next-version 4.1.0`, or +`dev --interactive` to review status markers and confirm the write. + Collectors that request additional access must be explicitly approved, for example: @@ -180,6 +189,11 @@ engine-wide `runtime` options from per-collector `collectors` settings: { "schema_version": 4, "runtime": {"default_max_workers": 4, "maximum_workers": 8}, + "maintenance": { + "local_manifest_path": "project.manifest.json", + "minimum_python": "3.11", + "recommended_python": "3.11" + }, "collectors": { "core.filesystem.system_drive_tree": {"max_entries": 5000, "max_depth": 12}, "core.process.memory_map": {"max_regions": 5000, "dump_directory": "memory_maps"} @@ -192,6 +206,13 @@ memory-map collector options are strictly typed and bounded before planning. Unknown engine fields, duplicate JSON keys, unsafe workspace paths, and invalid collector IDs fail closed; plugin-owned setting fields remain extensible. +An optional remote integrity manifest requires both an HTTPS +`remote_manifest_url` and a pinned lowercase `remote_manifest_sha256`. The exact +downloaded bytes are authenticated before strict JSON parsing. Integrity +manifests can contain only a schema version, project version, and file hashes; +they cannot configure, enable, or add collectors, and collection never depends +on the remote endpoint. + Schema-version `3` JSON files are migrated in memory without modifying the source file. Legacy `workers`/`worker_count`, `max_workers`, `output_root`, and `collector_settings` aliases become their v4 runtime or collector equivalents; diff --git a/TODO.md b/TODO.md index a8ce2590..251af449 100644 --- a/TODO.md +++ b/TODO.md @@ -414,7 +414,7 @@ Windows platform allows. checksums, enforce size limits, and register artifacts. - [x] **Packaging layer:** consume the finalized artifact catalog and create ZIP, hash, manifest, and summary outputs. -- [ ] **Maintenance layer:** keep update, debug, developer checks, and usage +- [x] **Maintenance layer:** keep update, debug, developer checks, and usage analytics from being coupled to normal collection. ### Make collection safe, bounded, and observable @@ -466,9 +466,9 @@ Windows platform allows. with documented collector membership. - [x] Allow explicit include/exclude collector selections to override a profile. - [x] Version the configuration schema and migrate older configuration files. -- [ ] Keep remote configuration optional, authenticated/validated, and out of the +- [x] Keep remote configuration optional, authenticated/validated, and out of the critical local collection path. -- [ ] Never let a remote manifest silently add a collector to a user's run. +- [x] Never let a remote manifest silently add a collector to a user's run. ### Improve the extension model @@ -606,7 +606,7 @@ Windows platform allows. - [x] Provide descriptive help text for every flag. - [x] Support the current action flags: `default`, `threaded`, `modded`, `depth`, `nopy`, `minimal`, `performance-check`, and `usage`. -- [ ] Support side actions: `debug`, `update`, and `dev`. +- [x] Support side actions: `debug`, `update`, and `dev`. - [x] Provide action/sub-action exclusivity validation and clear invalid-combination errors. - [ ] Provide colored console output for status, warnings, errors, and prompts. @@ -657,7 +657,7 @@ Windows platform allows. - [ ] Load `config.ini` from the project CODE directory. - [ ] Expose debug level, version, current file manifest, log retention, worker count, preference persistence, and all collector settings. -- [ ] Support remote configuration retrieval for update/integrity checks. +- [x] Support remote configuration retrieval for update/integrity checks. - [ ] Keep configuration parsing available to every collector through the package. ### Logging @@ -674,7 +674,7 @@ Windows platform allows. - [x] Provide an exception helper that logs and raises a requested exception type. - [x] Provide a deprecation decorator with removal version, reason, and optional stack trace. -- [ ] Preserve a dedicated DEBUG log and diagnostic context for `--debug`. +- [x] Preserve a dedicated DEBUG log and diagnostic context for `--debug`. ### Output and packaging @@ -817,15 +817,15 @@ appropriate default/deep/non-Python mode. ### Debugger (`--debug`) -- [ ] Fetch the remote configuration manifest. -- [ ] Compare local and remote versions, including snapshot-version handling. -- [ ] Compare the local file tree to the configured required-file manifest. -- [ ] Report missing and extra files. -- [ ] Check Sysinternals archive/binary state. +- [x] Fetch the remote configuration manifest. +- [x] Compare local and remote versions, including snapshot-version handling. +- [x] Compare the local file tree to the configured required-file manifest. +- [x] Report missing and extra files. +- [x] Check Sysinternals archive/binary state. - [x] Report admin/UAC state and PowerShell execution policy. - [x] Report Python, psutil, executable, prefix, virtual-environment, CPU, and logging context. -- [ ] Report whether the running Python version is recommended, supported, or +- [x] Report whether the running Python version is recommended, supported, or incompatible. - [x] Write diagnostic output to a dedicated debug log. @@ -840,14 +840,14 @@ appropriate default/deep/non-Python mode. ### Developer action (`--dev`) -- [ ] Present contribution and repository-organization checks interactively. +- [x] Present contribution and repository-organization checks interactively. - [ ] Check naming conventions, CODE placement, documentation/docstrings, and one-main-feature-per-file organization. -- [ ] Compare current files against the configured manifest. -- [ ] Display added, removed, and unchanged files with colored status markers. +- [x] Compare current files against the configured manifest. +- [x] Display added, removed, and unchanged files with colored status markers. - [ ] Update the manifest in `config.ini` after confirmation. -- [ ] Prompt for and validate the next semantic version. -- [ ] Keep excluded history/cache/Sysinternals files out of manifest comparisons. +- [x] Prompt for and validate the next semantic version. +- [x] Keep excluded history/cache/Sysinternals files out of manifest comparisons. ## 6. MODS extension system diff --git a/logicytics/cli.py b/logicytics/cli.py index 9f861873..57f037cd 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -11,13 +11,22 @@ import sys from pathlib import Path -from logicytics.configuration import load_config +from logicytics.configuration import AppConfig, load_config from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest from logicytics.discovery import preflight from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError from logicytics.manifest import MANIFEST_SCHEMA_VERSION from logicytics.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph +from logicytics.maintenance import ( + build_manifest, + compare_files, + developer_checks, + load_local_manifest, + local_version, + maintenance_diagnostics, + write_local_manifest, +) from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -109,7 +118,7 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--usage", action="store_true", help="Show local interaction statistics and create a usage graph.") parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") subcommands = parser.add_subparsers(dest="command") - for command in ("preflight", "debug", "update", "plan", "run"): + for command in ("preflight", "debug", "update", "dev", "plan", "run"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") subparser.add_argument( "--profile", @@ -204,9 +213,100 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Run collectors sequentially and write a per-collector duration report.", ) + if command == "dev": + subparser.add_argument( + "--write-manifest", + action="store_true", + help=( + "Write the reviewed local integrity manifest; requires --next-version " + "unless interactive." + ), + ) + subparser.add_argument( + "--next-version", + help=( + "Semantic version to validate and record in a newly written " + "integrity manifest." + ), + ) + subparser.add_argument( + "--interactive", + action="store_true", + help=( + "Show contribution checks and prompt before changing the local " + "integrity manifest." + ), + ) return parser +def _write_json(path: Path, payload: object) -> Path: + """Atomically write a human-readable JSON diagnostic artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(path) + return path + + +def _status_marker(label: str, count: int) -> str: + """Return a colored interactive repository-status marker when a terminal supports it.""" + color = "\033[32m" if count == 0 else "\033[33m" + reset = "\033[0m" + return f"{color}{label}: {count}{reset}" if sys.stdout.isatty() else f"{label}: {count}" + + +def _run_developer_action( + root: Path, configuration: AppConfig, arguments: argparse.Namespace +) -> int: + """Run read-only contribution checks and an explicitly confirmed manifest update.""" + settings = configuration.maintenance + existing = load_local_manifest(root, settings) + comparison = ( + compare_files(root, settings, existing) + if existing + else {"missing": [], "modified": [], "extra": [], "unchanged": []} + ) + checks = developer_checks(root, settings) + next_version = arguments.next_version + write_requested = arguments.write_manifest + if arguments.interactive: + print("Contribution and repository organization checks") + for name in ("missing", "modified", "extra", "unchanged"): + print(_status_marker(name.title(), len(comparison[name]))) + organization_checks = ( + "naming_violations", + "misplaced_python", + "missing_module_docstrings", + "crowded_modules", + ) + for name in organization_checks: + print(_status_marker(name.replace("_", " ").title(), len(checks[name]))) + if next_version is None: + entered = input(f"Next semantic version [{local_version(root)}]: ").strip() + next_version = entered or local_version(root) + answer = input("Write the reviewed integrity manifest? [y/N]: ").strip().casefold() + write_requested = answer in {"y", "yes"} + if arguments.write_manifest and next_version is None: + raise ValueError("--write-manifest requires --next-version unless --interactive is used") + manifest_path: str | None = None + if write_requested: + if next_version is None: + raise ValueError("a semantic next version is required to write the integrity manifest") + manifest = build_manifest(root, settings, next_version) + manifest_path = str(write_local_manifest(root, settings, manifest)) + payload = { + "checks": checks, + "comparison": comparison, + "current_version": local_version(root), + "existing_manifest_version": existing.version if existing else None, + "manifest_written": manifest_path, + "next_version": next_version, + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" parser = _parser() @@ -277,7 +377,11 @@ def main(argv: list[str] | None = None) -> int: "cpu_count": os.cpu_count()}, "sysinternals": ensure_sysinternals(root).to_dict(), "preflight": {"valid_collectors": len(report.valid), "invalid_collectors": len(report.invalid)}, + "maintenance": maintenance_diagnostics(root, configuration.maintenance), } + debug_path = configuration.runtime.output_root.parent / "logs" / "debug" / "debug.json" + payload["debug_log"] = str(debug_path) + _write_json(debug_path, payload) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if not report.invalid else 2 if arguments.command == "update": @@ -294,6 +398,8 @@ def main(argv: list[str] | None = None) -> int: "stderr": pulled.stderr}) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if not arguments.apply or payload.get("returncode") == 0 else 1 + if arguments.command == "dev": + return _run_developer_action(root, configuration, arguments) plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) if arguments.command == "plan": print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) @@ -323,7 +429,7 @@ def main(argv: list[str] | None = None) -> int: ) print(f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}") return 0 if outcome.manifest.status.value == "succeeded" else 1 - except (LogicyticsError, PermissionError, ValueError) as error: + except (LogicyticsError, OSError, PermissionError, ValueError) as error: print(f"Error: {error}") return 2 diff --git a/logicytics/configuration.py b/logicytics/configuration.py index d43153ea..5f10f0ad 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -21,11 +21,15 @@ r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|(?:plugin|mod)\.[a-z][a-z0-9_]*)$" ) _SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") -_ROOT_FIELDS = frozenset({"schema_version", "runtime", "interaction", "collectors"}) +_ROOT_FIELDS = frozenset({"schema_version", "runtime", "interaction", "maintenance", "collectors"}) _RUNTIME_FIELDS = frozenset({ "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", }) _INTERACTION_FIELDS = frozenset({"history_enabled", "similarity_threshold", "model_name", "model_debug"}) +_MAINTENANCE_FIELDS = frozenset({ + "remote_manifest_url", "remote_manifest_sha256", "local_manifest_path", + "minimum_python", "recommended_python", +}) _LEGACY_ROOT_FIELDS = _ROOT_FIELDS.union({ "collector_settings", "workers", "worker_count", "max_workers", "output_root", "package_completed_runs", "maximum_run_output_bytes", @@ -188,6 +192,7 @@ def _migrate_v3_configuration(raw: Mapping[str, Any], project_root: Path) -> dic "schema_version": SCHEMA_VERSION, "runtime": runtime, "interaction": raw.get("interaction", {}), + "maintenance": raw.get("maintenance", {}), "collectors": collectors, } @@ -213,6 +218,17 @@ class InteractionSettings: model_debug: bool = False +@dataclass(frozen=True, slots=True) +class MaintenanceSettings: + """Optional integrity sources and supported Python policy.""" + + remote_manifest_url: str | None = None + remote_manifest_sha256: str | None = None + local_manifest_path: Path = Path("project.manifest.json") + minimum_python: str = "3.11" + recommended_python: str = "3.11" + + @dataclass(frozen=True, slots=True) class AppConfig: """Validated settings loaded from an optional JSON configuration file.""" @@ -220,6 +236,7 @@ class AppConfig: schema_version: int runtime: RuntimeSettings interaction: InteractionSettings = field(default_factory=InteractionSettings) + maintenance: MaintenanceSettings = field(default_factory=MaintenanceSettings) collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) migrated_from_schema: int | None = None @@ -231,12 +248,14 @@ def to_manifest_dict(self) -> dict[str, Any]: """Return a JSON-safe, non-secret configuration snapshot.""" data = asdict(self) data["runtime"]["output_root"] = str(self.runtime.output_root) + data["maintenance"]["local_manifest_path"] = str(self.maintenance.local_manifest_path) return redact_mapping(data) def fingerprint(self) -> str: """Hash the complete validated configuration without persisting its secrets.""" data = asdict(self) data["runtime"]["output_root"] = str(self.runtime.output_root.resolve()) + data["maintenance"]["local_manifest_path"] = str(self.maintenance.local_manifest_path) serialized = json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True) return hashlib.sha256(serialized.encode("utf-8")).hexdigest() @@ -333,6 +352,58 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi if not isinstance(model_name, str) or not model_name.strip() or any(char in model_name for char in "\r\n"): raise PlanError("interaction model_name must be a non-empty single-line string") + maintenance_raw = raw.get("maintenance", {}) + if not isinstance(maintenance_raw, dict): + raise PlanError("maintenance configuration must be an object") + unknown_maintenance = sorted(set(maintenance_raw) - _MAINTENANCE_FIELDS) + if unknown_maintenance: + raise PlanError( + "maintenance configuration contains unsupported settings: " + f"{', '.join(unknown_maintenance)}" + ) + remote_url = maintenance_raw.get("remote_manifest_url") + remote_sha256 = maintenance_raw.get("remote_manifest_sha256") + if (remote_url is None) != (remote_sha256 is None): + raise PlanError("remote manifest URL and SHA-256 must be configured together") + if remote_url is not None and ( + not isinstance(remote_url, str) + or not remote_url.startswith("https://") + or "\n" in remote_url + ): + raise PlanError("remote_manifest_url must be an HTTPS URL") + if remote_sha256 is not None and ( + not isinstance(remote_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", remote_sha256) is None + ): + raise PlanError("remote_manifest_sha256 must be a lowercase SHA-256 digest") + local_manifest_value = maintenance_raw.get("local_manifest_path", "project.manifest.json") + if not isinstance(local_manifest_value, str) or not local_manifest_value.strip(): + raise PlanError("local_manifest_path must be a non-empty relative path") + local_manifest_path = Path(local_manifest_value) + if ( + local_manifest_path.is_absolute() + or local_manifest_path.drive + or ".." in local_manifest_path.parts + ): + raise PlanError("local_manifest_path must remain inside the project") + version_pattern = re.compile(r"^\d+\.\d+$") + minimum_python = maintenance_raw.get("minimum_python", "3.11") + recommended_python = maintenance_raw.get("recommended_python", "3.11") + if ( + not isinstance(minimum_python, str) + or version_pattern.fullmatch(minimum_python) is None + ): + raise PlanError("minimum_python must use major.minor form") + if ( + not isinstance(recommended_python, str) + or version_pattern.fullmatch(recommended_python) is None + ): + raise PlanError("recommended_python must use major.minor form") + if tuple(map(int, recommended_python.split("."))) < tuple( + map(int, minimum_python.split(".")) + ): + raise PlanError("recommended_python must not be older than minimum_python") + collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( isinstance(key, str) and isinstance(value, dict) @@ -355,6 +426,13 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi model_name=model_name, model_debug=model_debug, ), + maintenance=MaintenanceSettings( + remote_manifest_url=remote_url, + remote_manifest_sha256=remote_sha256, + local_manifest_path=local_manifest_path, + minimum_python=minimum_python, + recommended_python=recommended_python, + ), collector_settings=collector_settings, migrated_from_schema=migrated_from_schema, ) diff --git a/logicytics/maintenance.py b/logicytics/maintenance.py new file mode 100644 index 00000000..5f66141b --- /dev/null +++ b/logicytics/maintenance.py @@ -0,0 +1,354 @@ +"""Authenticated manifests, file integrity, version checks, and developer audits.""" + +from __future__ import annotations + +import ast +import hashlib +import hmac +import json +import re +import sys +import tomllib +import urllib.request +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Mapping + +from logicytics.configuration import MaintenanceSettings + +_SHA256 = re.compile(r"^[0-9a-f]{64}$") +_VERSION = re.compile( + r"^(\d+)\.(\d+)\.(\d+)(?:(?:-([0-9A-Za-z.-]+))|((?:a|b|rc|\.dev)\d+))?$" +) +_MAXIMUM_MANIFEST_BYTES = 2 * 1024 * 1024 +_EXCLUDED_PARTS = frozenset( + { + ".git", + ".idea", + ".mypy_cache", + ".pytest_cache", + ".tox", + ".venv", + "__pycache__", + "output", + "venv", + } +) + + +@dataclass(frozen=True, slots=True) +class IntegrityManifest: + """A validated version and required-file digest map.""" + + version: str + files: Mapping[str, str] + source: str + + +def sha256_path(path: Path) -> str: + """Hash a regular file with bounded reads.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + """Reject duplicate JSON keys instead of silently accepting the last value.""" + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"integrity manifest contains duplicate key {key!r}") + result[key] = value + return result + + +def _decode_json(payload: bytes | str, source: str) -> object: + """Decode strict UTF-8 JSON with duplicate-key detection.""" + try: + text = payload.decode("utf-8") if isinstance(payload, bytes) else payload + return json.loads(text, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError(f"{source} integrity manifest is not valid UTF-8 JSON: {error}") from error + + +def _parse_manifest(payload: object, source: str) -> IntegrityManifest: + """Reject ambiguous or execution-bearing remote/local manifest fields.""" + allowed_fields = {"schema_version", "version", "files"} + if not isinstance(payload, dict) or set(payload) != allowed_fields: + raise ValueError("integrity manifest must contain only schema_version, version, and files") + if payload["schema_version"] != 1: + raise ValueError("integrity manifest schema_version must be 1") + version = payload["version"] + files = payload["files"] + if not isinstance(version, str) or _VERSION.fullmatch(version) is None: + raise ValueError("integrity manifest version must use semantic versioning") + if not isinstance(files, dict): + raise ValueError("integrity manifest files must be an object") + validated: dict[str, str] = {} + for name, digest in files.items(): + if ( + not isinstance(name, str) + or not isinstance(digest, str) + or _SHA256.fullmatch(digest) is None + ): + raise ValueError("integrity manifest file entries require path and SHA-256 strings") + path = PurePosixPath(name) + if ( + name == "." + or path.is_absolute() + or ".." in path.parts + or path.as_posix() != name + or "\\" in name + ): + raise ValueError(f"integrity manifest path is unsafe: {name!r}") + if any( + part in _EXCLUDED_PARTS or part.startswith(".") and part != ".github" + for part in path.parts + ): + raise ValueError(f"integrity manifest path is excluded: {name!r}") + validated[name] = digest + return IntegrityManifest(version, dict(sorted(validated.items())), source) + + +def fetch_remote_manifest(settings: MaintenanceSettings) -> IntegrityManifest | None: + """Fetch an optional HTTPS manifest and verify exact pinned bytes before parsing.""" + if settings.remote_manifest_url is None: + return None + assert settings.remote_manifest_sha256 is not None + request = urllib.request.Request( + settings.remote_manifest_url, + headers={"User-Agent": "Logicytics/4 integrity-check"}, + method="GET", + ) + with urllib.request.urlopen(request, timeout=10) as response: + payload = response.read(_MAXIMUM_MANIFEST_BYTES + 1) + if len(payload) > _MAXIMUM_MANIFEST_BYTES: + raise ValueError("remote integrity manifest exceeds the 2 MiB limit") + digest = hashlib.sha256(payload).hexdigest() + if not hmac.compare_digest(digest, settings.remote_manifest_sha256): + raise ValueError("remote integrity manifest SHA-256 does not match its configured pin") + raw = _decode_json(payload, "remote") + return _parse_manifest(raw, settings.remote_manifest_url) + + +def load_local_manifest( + project_root: Path, settings: MaintenanceSettings +) -> IntegrityManifest | None: + """Load the configured project-owned manifest without leaving the repository.""" + path = (project_root / settings.local_manifest_path).resolve() + try: + path.relative_to(project_root.resolve()) + except ValueError as error: + raise ValueError("local integrity manifest escapes the project") from error + if not path.exists(): + return None + try: + raw = _decode_json(path.read_text(encoding="utf-8"), "local") + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError(f"local integrity manifest is invalid: {error}") from error + return _parse_manifest(raw, str(path)) + + +def project_files(project_root: Path, settings: MaintenanceSettings) -> tuple[Path, ...]: + """List manifest-eligible files while excluding outputs, caches, history, and tools.""" + root = project_root.resolve() + manifest_path = (root / settings.local_manifest_path).resolve() + files: list[Path] = [] + for path in root.rglob("*"): + if not path.is_file() or path.is_symlink() or path.resolve() == manifest_path: + continue + relative = path.relative_to(root) + if any(part in _EXCLUDED_PARTS for part in relative.parts): + continue + lowered_parts = tuple(part.casefold() for part in relative.parts) + if len(lowered_parts) >= 2 and lowered_parts[:2] == ("tools", "sysinternals"): + continue + if path.name in {"interaction_history.json.gz", "flag_usage.svg"}: + continue + files.append(path) + return tuple(sorted(files)) + + +def build_manifest( + project_root: Path, settings: MaintenanceSettings, version: str +) -> IntegrityManifest: + """Create a deterministic required-file manifest from eligible project files.""" + if _VERSION.fullmatch(version) is None: + raise ValueError("next version must use semantic versioning") + root = project_root.resolve() + files = { + path.relative_to(root).as_posix(): sha256_path(path) + for path in project_files(root, settings) + } + return IntegrityManifest(version, files, "generated") + + +def write_local_manifest( + project_root: Path, + settings: MaintenanceSettings, + manifest: IntegrityManifest, +) -> Path: + """Atomically publish an explicitly approved local manifest update.""" + path = (project_root / settings.local_manifest_path).resolve() + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text( + json.dumps( + {"schema_version": 1, "version": manifest.version, "files": manifest.files}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) + temporary.replace(path) + return path + + +def compare_files( + project_root: Path, + settings: MaintenanceSettings, + manifest: IntegrityManifest, +) -> dict[str, list[str]]: + """Report exact missing, modified, extra, and unchanged project files.""" + root = project_root.resolve() + current = { + path.relative_to(root).as_posix(): sha256_path(path) + for path in project_files(root, settings) + } + expected = dict(manifest.files) + return { + "missing": sorted(set(expected) - set(current)), + "modified": sorted( + name + for name in set(expected).intersection(current) + if expected[name] != current[name] + ), + "extra": sorted(set(current) - set(expected)), + "unchanged": sorted( + name + for name in set(expected).intersection(current) + if expected[name] == current[name] + ), + } + + +def local_version(project_root: Path) -> str: + """Read the authoritative package version from pyproject.toml.""" + with (project_root / "pyproject.toml").open("rb") as stream: + payload = tomllib.load(stream) + version = payload.get("project", {}).get("version") + if not isinstance(version, str) or _VERSION.fullmatch(version) is None: + raise ValueError("pyproject.toml does not contain a semantic project version") + return version + + +def compare_versions(local: str, remote: str) -> str: + """Compare semantic versions while treating snapshots as older than stable peers.""" + def parsed(value: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]]: + match = _VERSION.fullmatch(value) + if match is None: + raise ValueError("version comparison requires semantic versions") + major, minor, patch, prerelease, pep_prerelease = match.groups() + identifiers: tuple[tuple[int, int | str], ...] = () + if pep_prerelease is not None: + pep_match = re.fullmatch(r"(a|b|rc|\.dev)(\d+)", pep_prerelease) + assert pep_match is not None + label, number = pep_match.groups() + identifiers = ((0, {".dev": 0, "a": 1, "b": 2, "rc": 3}[label]), (0, int(number))) + elif prerelease is not None: + identifiers = tuple( + (0, int(identifier)) if identifier.isdigit() else (1, identifier) + for identifier in prerelease.split(".") + ) + is_stable = prerelease is None and pep_prerelease is None + return int(major), int(minor), int(patch), 1 if is_stable else 0, identifiers + + left, right = parsed(local), parsed(remote) + return "current" if left == right else "behind" if left < right else "ahead" + + +def python_support(settings: MaintenanceSettings) -> dict[str, str]: + """Classify the running Python as recommended, supported, or incompatible.""" + running = (sys.version_info.major, sys.version_info.minor) + minimum = tuple(map(int, settings.minimum_python.split("."))) + recommended = tuple(map(int, settings.recommended_python.split("."))) + status = ( + "incompatible" + if running < minimum + else "recommended" + if running == recommended + else "supported" + ) + return { + "running": f"{running[0]}.{running[1]}", + "minimum": settings.minimum_python, + "recommended": settings.recommended_python, + "status": status, + } + + +def maintenance_diagnostics( + project_root: Path, settings: MaintenanceSettings +) -> dict[str, object]: + """Resolve optional remote/local integrity evidence without changing collection state.""" + remote = fetch_remote_manifest(settings) + local_manifest = load_local_manifest(project_root, settings) + selected = remote or local_manifest + return { + "python_support": python_support(settings), + "local_version": local_version(project_root), + "manifest_source": selected.source if selected else None, + "manifest_version": selected.version if selected else None, + "version_status": ( + compare_versions(local_version(project_root), selected.version) + if selected + else "unconfigured" + ), + "files": ( + compare_files(project_root, settings, selected) + if selected + else {"missing": [], "modified": [], "extra": [], "unchanged": []} + ), + "remote_enabled": remote is not None, + } + + +def developer_checks(project_root: Path, settings: MaintenanceSettings) -> dict[str, object]: + """Inspect repository organization without importing or executing project modules.""" + naming: list[str] = [] + missing_docstrings: list[str] = [] + crowded_modules: list[str] = [] + misplaced_python: list[str] = [] + root = project_root.resolve() + for path in project_files(root, settings): + relative = path.relative_to(root) + if path.suffix.casefold() != ".py": + continue + if path.stem != "__init__" and re.fullmatch(r"[a-z][a-z0-9_]*", path.stem) is None: + naming.append(relative.as_posix()) + if relative.parts[0] not in {"logicytics", "tests"} and len(relative.parts) > 1: + misplaced_python.append(relative.as_posix()) + try: + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(relative)) + except (OSError, UnicodeDecodeError, SyntaxError): + missing_docstrings.append(relative.as_posix()) + continue + if ast.get_docstring(tree) is None: + missing_docstrings.append(relative.as_posix()) + public_features = [ + node.name for node in tree.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) + and not node.name.startswith("_") + ] + if len(public_features) > 12: + crowded_modules.append(relative.as_posix()) + return { + "naming_violations": sorted(naming), + "misplaced_python": sorted(misplaced_python), + "missing_module_docstrings": sorted(missing_docstrings), + "crowded_modules": sorted(crowded_modules), + "eligible_file_count": len(project_files(root, settings)), + } diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 663a17c7..36c210cd 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -17,7 +17,7 @@ from dataclasses import FrozenInstanceError from datetime import datetime from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch from logicytics import packaging from logicytics import load_configuration, plan_run, query_run, read_artifact, run_collection @@ -28,8 +28,16 @@ from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed from logicytics.interaction import load_history, match_flag, usage_statistics from logicytics.manifest import write_manifest +from logicytics.maintenance import ( + build_manifest, + compare_files, + compare_versions, + fetch_remote_manifest, + project_files, + write_local_manifest, +) from logicytics.sysinternals import ensure_sysinternals -from logicytics.configuration import default_config, load_config +from logicytics.configuration import MaintenanceSettings, default_config, load_config from logicytics.contracts import ( Artifact, Capability, @@ -183,6 +191,155 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_maintenance_configuration_requires_pinned_https_and_python_order( + self, + ) -> None: + """Integrity endpoints and Python policy fail closed during configuration loading.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config = root / "logicytics.json" + valid = { + "schema_version": 4, + "maintenance": { + "remote_manifest_url": "https://example.invalid/project.manifest.json", + "remote_manifest_sha256": "a" * 64, + "local_manifest_path": "integrity/project.json", + "minimum_python": "3.11", + "recommended_python": "3.12", + }, + } + config.write_text(json.dumps(valid), encoding="utf-8") + loaded = load_config(root, config) + self.assertEqual( + Path("integrity/project.json"), loaded.maintenance.local_manifest_path + ) + self.assertEqual("3.12", loaded.maintenance.recommended_python) + + for mutation in ( + {"remote_manifest_url": "http://example.invalid/project.json"}, + {"remote_manifest_sha256": None}, + {"local_manifest_path": "../escape.json"}, + {"minimum_python": "3.12", "recommended_python": "3.11"}, + ): + invalid = json.loads(json.dumps(valid)) + invalid["maintenance"].update(mutation) + config.write_text(json.dumps(invalid), encoding="utf-8") + with self.subTest(mutation=mutation), self.assertRaises(PlanError): + load_config(root, config) + + def test_authenticated_remote_manifest_is_strict_data_only_configuration( + self, + ) -> None: + """Pinned HTTPS bytes are accepted, while unpinned or execution-bearing data is rejected.""" + payload = json.dumps( + { + "schema_version": 1, + "version": "4.1.0-snapshot.2", + "files": {"logicytics/cli.py": "a" * 64}, + } + ).encode("utf-8") + settings = MaintenanceSettings( + remote_manifest_url="https://example.invalid/project.manifest.json", + remote_manifest_sha256=hashlib.sha256(payload).hexdigest(), + ) + response = MagicMock() + response.__enter__.return_value.read.return_value = payload + with patch("logicytics.maintenance.urllib.request.urlopen", return_value=response): + manifest = fetch_remote_manifest(settings) + self.assertIsNotNone(manifest) + self.assertEqual("4.1.0-snapshot.2", manifest.version) + + tampered = MaintenanceSettings( + remote_manifest_url=settings.remote_manifest_url, + remote_manifest_sha256="0" * 64, + ) + with patch("logicytics.maintenance.urllib.request.urlopen", return_value=response): + with self.assertRaisesRegex(ValueError, "does not match"): + fetch_remote_manifest(tampered) + + execution_payload = json.dumps( + { + "schema_version": 1, + "version": "4.1.0", + "files": {}, + "collectors": ["remote.code"], + } + ).encode("utf-8") + execution_response = MagicMock() + execution_response.__enter__.return_value.read.return_value = execution_payload + execution_settings = MaintenanceSettings( + remote_manifest_url=settings.remote_manifest_url, + remote_manifest_sha256=hashlib.sha256(execution_payload).hexdigest(), + ) + with patch( + "logicytics.maintenance.urllib.request.urlopen", + return_value=execution_response, + ): + with self.assertRaisesRegex(ValueError, "only schema_version"): + fetch_remote_manifest(execution_settings) + + def test_integrity_manifest_comparison_and_snapshot_version_ordering(self) -> None: + """Developer integrity reports file states and compare snapshots semantically.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + settings = MaintenanceSettings() + (root / "same.txt").write_text("same", encoding="utf-8") + (root / "changed.txt").write_text("before", encoding="utf-8") + (root / "removed.txt").write_text("remove", encoding="utf-8") + manifest = build_manifest(root, settings, "4.1.0-snapshot.2") + write_local_manifest(root, settings, manifest) + (root / "changed.txt").write_text("after", encoding="utf-8") + (root / "removed.txt").unlink() + (root / "added.txt").write_text("add", encoding="utf-8") + comparison = compare_files(root, settings, manifest) + self.assertEqual(["removed.txt"], comparison["missing"]) + self.assertEqual(["changed.txt"], comparison["modified"]) + self.assertEqual(["added.txt"], comparison["extra"]) + self.assertEqual(["same.txt"], comparison["unchanged"]) + self.assertEqual("behind", compare_versions("4.1.0-snapshot.2", "4.1.0")) + self.assertEqual( + "behind", compare_versions("4.1.0-snapshot.2", "4.1.0-snapshot.10") + ) + cache = root / ".pytest_cache" / "ignored.txt" + cache.parent.mkdir() + cache.write_text("ignored", encoding="utf-8") + tool = root / "tools" / "Sysinternals" / "ignored.exe" + tool.parent.mkdir(parents=True) + tool.write_bytes(b"ignored") + names = {path.relative_to(root).as_posix() for path in project_files(root, settings)} + self.assertNotIn(".pytest_cache/ignored.txt", names) + self.assertNotIn("tools/Sysinternals/ignored.exe", names) + + def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> None: + """Side actions remain separate from collection and write only their dedicated artifacts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "pyproject.toml").write_text( + '[project]\nname = "fixture"\nversion = "4.0.0"\n', + encoding="utf-8", + ) + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", new_callable=io.StringIO + ): + self.assertEqual(0, main(["dev", "--write-manifest", "--next-version", "4.1.0"])) + manifest_path = root / "project.manifest.json" + self.assertTrue(manifest_path.is_file()) + manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual("4.1.0", manifest_payload["version"]) + + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", new_callable=io.StringIO + ): + self.assertEqual(0, main(["debug"])) + debug_path = root / "output" / "logs" / "debug" / "debug.json" + debug = json.loads(debug_path.read_text(encoding="utf-8")) + self.assertEqual("4.0.0", debug["maintenance"]["local_version"]) + self.assertIn( + debug["maintenance"]["python_support"]["status"], + {"recommended", "supported", "incompatible"}, + ) + self.assertEqual("missing", debug["sysinternals"]["status"]) + def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> None: """Legacy scripts enter the pipeline only through typed metadata and worker isolation.""" with tempfile.TemporaryDirectory() as temporary: From ce6dbbcb1c2370e5c6fb8540adf12dccdbe808d4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 17:59:35 +0300 Subject: [PATCH 175/316] feat: enforce collector output media contracts Require every shipped collector to declare its complete MIME output set and statically verify literal artifact registrations during preflight. Defend the same boundary inside isolated workers so returned artifacts cannot use undeclared media types, while allowing declared conditional outputs that a particular run does not produce. Document the unified specialty and output contract, cover missing and mismatched declarations, and mark the verified specialty requirements complete. --- README.md | 6 +++ TODO.md | 4 +- core/bluetooth/bluetooth_addresses.py | 1 + core/bluetooth/bluetooth_history.py | 1 + core/bluetooth/paired_devices.py | 1 + core/browser/browser_data_backup.py | 1 + core/diagnostics/sysinternals_report.py | 1 + core/encryption/bitlocker_status.py | 1 + core/encryption/bitlocker_volumes.py | 1 + core/event_log/application_events.py | 1 + core/event_log/security_events.py | 1 + core/event_log/system_events.py | 1 + core/filesystem/sensitive_file_inventory.py | 1 + core/filesystem/startup_folder_entries.py | 1 + core/filesystem/system_drive_listing.py | 1 + core/filesystem/system_drive_tree.py | 1 + core/hardware/battery_status.py | 1 + core/hardware/display_adapters.py | 1 + core/hardware/windows_features.py | 1 + core/media/media_backup.py | 1 + core/memory/memory_snapshot.py | 1 + core/network/active_connections.py | 1 + core/network/adapter_statistics.py | 1 + core/network/arp_cache.py | 1 + core/network/bandwidth_sample.py | 1 + core/network/connection_processes.py | 1 + core/network/dns_cache.py | 1 + core/network/firewall_profiles.py | 1 + core/network/network_adapters.py | 1 + core/network/network_identity.py | 1 + core/network/network_interfaces.py | 1 + core/network/routing_table.py | 1 + core/packet/connection_graph.py | 1 + core/packet/packet_capture.py | 1 + core/process/detailed_processes.py | 1 + core/process/memory_map.py | 1 + core/process/process_memory.py | 1 + core/process/running_processes.py | 1 + core/registry/hklm_backup.py | 1 + core/registry/installed_applications.py | 1 + core/registry/startup_applications.py | 1 + core/ssh/ssh_backup.py | 1 + core/storage/logical_drives.py | 1 + core/storage/mounted_volumes.py | 1 + core/storage/physical_disks.py | 1 + core/storage/volume_details.py | 1 + core/system/bios_info.py | 1 + core/system/computer_system.py | 1 + core/system/defender_status.py | 1 + core/system/environment_posture.py | 1 + core/system/group_policy.py | 1 + core/system/installed_drivers.py | 1 + core/system/installed_updates.py | 1 + core/system/local_accounts.py | 1 + core/system/operating_system.py | 1 + core/system/scheduled_tasks.py | 1 + core/system/session_snapshot.py | 1 + core/system/system_details.py | 1 + core/system/system_diagnostics.py | 1 + core/system/system_info.py | 1 + core/system/windows_services.py | 1 + core/system/windows_system_data_backup.py | 1 + core/usb/usb_storage_inventory.py | 1 + core/wireless/wifi_interfaces.py | 1 + core/wireless/wifi_profile_keys.py | 1 + core/wireless/wifi_profiles.py | 1 + logicytics/discovery.py | 53 +++++++++++++++++++-- logicytics/runtime.py | 12 +++++ tests/test_core_functionality.py | 36 +++++++++++++- 69 files changed, 168 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 6c20af2b..eca57f01 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,12 @@ from `CoreCollector`, provide documented and typed `metadata`, `validate`, `collect`, and `cleanup` methods, and use a matching ID/specialty. Plugins follow the equivalent `PluginCollector` contract. +Every collector explicitly declares the exact MIME types it can register. +Preflight statically compares literal `register_file(..., media_type=...)` calls +with that declaration, and isolated workers enforce the declaration again on +the returned artifact catalog. The collector folder, ID, class, description, +source category, and primary specialty therefore remain one coherent contract. + Collectors may import the public collector contracts, but not CLI, planning, runtime, packaging, configuration, or public application-control services. Even with an approved subprocess capability, workers cannot invoke Logicytics, another diff --git a/TODO.md b/TODO.md index 251af449..dc21fc10 100644 --- a/TODO.md +++ b/TODO.md @@ -255,9 +255,9 @@ that fails any mandatory rule is not a collector and must not be launched. - [x] Require each collector to have one primary specialty. Related secondary categories may be declared as metadata but may not change the collector's identity. -- [ ] Require the specialty to match the declared artifact types and the collector's +- [x] Require the specialty to match the declared artifact types and the collector's documented responsibility. -- [ ] Reject a collector whose name, metadata, class, or outputs claim one specialty +- [x] Reject a collector whose name, metadata, class, or outputs claim one specialty while its implementation declares or performs an unrelated primary job. - [ ] Require a separate collector when a feature has a different permission model, timeout profile, sensitive-data classification, or output contract. diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index e9d77b5c..29a1be78 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -32,6 +32,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.bluetooth.bluetooth_addresses", name="Bluetooth addresses", version="4.0.0", specialty=Specialty.BLUETOOTH, + output_media_types=("application/json",), description="Exports paired Bluetooth friendly names and address-like identifiers from PnP data.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index 02f4c3ec..a0a6ce7b 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -26,6 +26,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.bluetooth.bluetooth_history", name="Bluetooth history snapshot", version="4.0.0", specialty=Specialty.BLUETOOTH, + output_media_types=("application/json",), description="Exports a timestamped Bluetooth PnP snapshot; retained run packages provide historical evidence.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("device_identifiers",), default_profiles=("deep",), timeout_seconds=45, diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py index f9693213..a5fd458a 100644 --- a/core/bluetooth/paired_devices.py +++ b/core/bluetooth/paired_devices.py @@ -34,6 +34,7 @@ def metadata(cls) -> CollectorMetadata: name="Bluetooth devices", version="4.0.0", specialty=Specialty.BLUETOOTH, + output_media_types=("application/json",), description="Captures available Bluetooth Plug and Play device metadata through PowerShell.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index 7e009179..d44bbc1a 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -40,6 +40,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.browser.browser_data_backup", name="Browser data backup", version="4.0.0", specialty=Specialty.BROWSER, + output_media_types=("application/octet-stream",), description="Copies bounded local profile evidence from Edge, Chrome, Firefox, Opera, and Opera GX.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.BROWSER_DATA, Capability.SENSITIVE_FILES), diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py index 17be6a2e..d21b97d8 100644 --- a/core/diagnostics/sysinternals_report.py +++ b/core/diagnostics/sysinternals_report.py @@ -22,6 +22,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.diagnostics.sysinternals_report", name="Sysinternals report", version="4.0.0", specialty=Specialty.DIAGNOSTICS, + output_media_types=("text/plain",), description="Reports supported Sysinternals binary/archive state and consolidates available tool output.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index cdfc090f..9275a57b 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -30,6 +30,7 @@ def metadata(cls) -> CollectorMetadata: name="BitLocker status", version="4.0.0", specialty=Specialty.ENCRYPTION, + output_media_types=("text/plain",), description="Exports local drive BitLocker status through the read-only manage-bde command.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index dc039af1..3f557dd6 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -40,6 +40,7 @@ def metadata(cls) -> CollectorMetadata: name="BitLocker volumes", version="4.0.0", specialty=Specialty.ENCRYPTION, + output_media_types=("application/json",), description="Exports local BitLocker volume metadata through read-only Get-BitLockerVolume.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 8395812e..266101e0 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -28,6 +28,7 @@ def metadata(cls) -> CollectorMetadata: name="Application event log", version="4.0.0", specialty=Specialty.EVENT_LOG, + output_media_types=("text/csv",), description="Exports up to 1,000 local Windows Application events as CSV.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index 392491ae..268c4ff4 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -30,6 +30,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.event_log.security_events", name="Security event log", version="4.0.0", specialty=Specialty.EVENT_LOG, + output_media_types=("text/csv",), description="Exports up to 1,000 local Windows Security events as CSV.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py index 0b306176..d35e274b 100644 --- a/core/event_log/system_events.py +++ b/core/event_log/system_events.py @@ -35,6 +35,7 @@ def metadata(cls) -> CollectorMetadata: name="System event log", version="4.0.0", specialty=Specialty.EVENT_LOG, + output_media_types=("text/csv",), description="Exports up to 1,000 local Windows System events as CSV.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index ca602957..d0146a4c 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -36,6 +36,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.filesystem.sensitive_file_inventory", name="Sensitive file inventory", version="4.0.0", specialty=Specialty.FILESYSTEM, + output_media_types=("application/octet-stream",), description="Finds and copies bounded supported files with sensitive-data keywords in their names.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), diff --git a/core/filesystem/startup_folder_entries.py b/core/filesystem/startup_folder_entries.py index 3d6126d8..fd39e0e0 100644 --- a/core/filesystem/startup_folder_entries.py +++ b/core/filesystem/startup_folder_entries.py @@ -35,6 +35,7 @@ def metadata(cls) -> CollectorMetadata: name="Startup folder entries", version="4.0.0", specialty=Specialty.FILESYSTEM, + output_media_types=("application/json",), description="Exports names, locations, sizes, and timestamps for up to 500 Startup-folder entries.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py index 1739e305..a0114605 100644 --- a/core/filesystem/system_drive_listing.py +++ b/core/filesystem/system_drive_listing.py @@ -51,6 +51,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.filesystem.system_drive_listing", name="System drive listing", version="4.0.0", specialty=Specialty.FILESYSTEM, + output_media_types=("text/plain",), description="Exports a configurable, bounded threaded recursive listing of the Windows system drive.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py index ca935eec..3fbae676 100644 --- a/core/filesystem/system_drive_tree.py +++ b/core/filesystem/system_drive_tree.py @@ -29,6 +29,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.filesystem.system_drive_tree", name="System drive tree", version="4.0.0", specialty=Specialty.FILESYSTEM, + output_media_types=("text/plain",), description="Exports a configurable bounded recursive directory tree for the Windows system drive.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index 4c5fdbb7..e7c3be93 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -24,6 +24,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated battery-status JSON artifact contract.""" return CollectorMetadata( id="core.hardware.battery_status", name="Battery status", version="4.0.0", specialty=Specialty.HARDWARE, + output_media_types=("application/json",), description="Exports local battery name, status, charge, capacity, and estimated runtime metadata.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py index 1ee2d488..efc7e53c 100644 --- a/core/hardware/display_adapters.py +++ b/core/hardware/display_adapters.py @@ -18,6 +18,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated display-adapter JSON artifact contract.""" return CollectorMetadata( id="core.hardware.display_adapters", name="Display adapters", version="4.0.0", specialty=Specialty.HARDWARE, + output_media_types=("application/json",), description="Exports local display-adapter names, driver versions, resolution, and memory metadata.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("hardware_inventory",), default_profiles=("deep",), timeout_seconds=30, diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index 4be2f2e3..e5320b9d 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -34,6 +34,7 @@ def metadata(cls) -> CollectorMetadata: name="Windows optional features", version="4.0.0", specialty=Specialty.HARDWARE, + output_media_types=("application/json",), description="Exports Windows optional-feature names and enabled states through PowerShell.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/media/media_backup.py b/core/media/media_backup.py index b1b81194..ffd93a99 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -23,6 +23,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent media backup artifact contract.""" return CollectorMetadata( id="core.media.media_backup", name="Pictures and Videos backup", version="4.0.0", specialty=Specialty.MEDIA, + output_media_types=("application/octet-stream",), description="Copies bounded JPG, JPEG, PNG, and MP4 files from current-user Pictures and Videos folders.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index 610b3482..a2d599f1 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -46,6 +46,7 @@ def metadata(cls) -> CollectorMetadata: name="Memory snapshot", version="4.0.0", specialty=Specialty.MEMORY, + output_media_types=("application/json",), description="Captures aggregate physical, virtual, and page-file memory statistics.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/active_connections.py b/core/network/active_connections.py index 95a0a070..e95e3df5 100644 --- a/core/network/active_connections.py +++ b/core/network/active_connections.py @@ -26,6 +26,7 @@ def metadata(cls) -> CollectorMetadata: name="Active network connections", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("text/plain",), description="Exports active TCP/UDP endpoints, states, and owning PIDs from netstat.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py index b18e9fe2..8560660d 100644 --- a/core/network/adapter_statistics.py +++ b/core/network/adapter_statistics.py @@ -27,6 +27,7 @@ def metadata(cls) -> CollectorMetadata: name="Network adapter statistics", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("application/json",), description="Exports per-interface network byte, packet, error, and discard counters.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/arp_cache.py b/core/network/arp_cache.py index 5f0797d6..bdeed453 100644 --- a/core/network/arp_cache.py +++ b/core/network/arp_cache.py @@ -33,6 +33,7 @@ def metadata(cls) -> CollectorMetadata: name="ARP cache", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("text/plain",), description="Exports the local ARP cache without sending network traffic.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index 81201b6d..78d03688 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -35,6 +35,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.network.bandwidth_sample", name="Network bandwidth sample", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("application/json",), description="Calculates local per-interface average and peak bandwidth from adapter counter samples.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py index 1d1d9c77..cefbb80d 100644 --- a/core/network/connection_processes.py +++ b/core/network/connection_processes.py @@ -56,6 +56,7 @@ def metadata(cls) -> CollectorMetadata: name="Connection process associations", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("text/csv",), description="Correlates active Netstat TCP/UDP endpoints with local process names and PIDs.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py index fed07ba3..846d96f8 100644 --- a/core/network/dns_cache.py +++ b/core/network/dns_cache.py @@ -23,6 +23,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated sensitive DNS-cache artifact contract.""" return CollectorMetadata( id="core.network.dns_cache", name="DNS resolver cache", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("text/plain",), description="Exports local DNS resolver cache records through read-only ipconfig output.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index 7324c5a2..b1416e28 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -24,6 +24,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated firewall-profile JSON artifact contract.""" return CollectorMetadata( id="core.network.firewall_profiles", name="Firewall profiles", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("application/json",), description="Exports local Domain, Private, and Public Windows firewall profile settings.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py index 24f0f7ab..1a2c35e1 100644 --- a/core/network/network_adapters.py +++ b/core/network/network_adapters.py @@ -27,6 +27,7 @@ def metadata(cls) -> CollectorMetadata: name="Network adapters", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("text/plain",), description="Captures local Windows IP configuration and adapter details through ipconfig.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/network_identity.py b/core/network/network_identity.py index ad2d09b6..24376556 100644 --- a/core/network/network_identity.py +++ b/core/network/network_identity.py @@ -29,6 +29,7 @@ def metadata(cls) -> CollectorMetadata: name="Network identity", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("application/json",), description="Records the local hostname and resolver-provided IP addresses without probing hosts.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index 6602126a..8b648248 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -47,6 +47,7 @@ def metadata(cls) -> CollectorMetadata: name="Network interfaces", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("application/json",), description="Exports IPv4 addresses, masks, broadcasts, link states, speeds, and duplex data.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/network/routing_table.py b/core/network/routing_table.py index 776c7f17..5fa9d00f 100644 --- a/core/network/routing_table.py +++ b/core/network/routing_table.py @@ -33,6 +33,7 @@ def metadata(cls) -> CollectorMetadata: name="Routing table", version="4.0.0", specialty=Specialty.NETWORK, + output_media_types=("text/plain",), description="Exports the local IPv4 and IPv6 routing tables without changing routes.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 6988b6a8..8aa1db8d 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -23,6 +23,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated network graph artifact contract.""" return CollectorMetadata( id="core.packet.connection_graph", name="Connection graph", version="4.0.0", specialty=Specialty.PACKET, + output_media_types=("text/vnd.graphviz",), description="Exports a DOT source/destination graph with TCP or UDP protocol edge labels.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index 8dd4dd74..a2a84bdf 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -49,6 +49,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the explicitly approved packet-capture CSV artifact contract.""" return CollectorMetadata( id="core.packet.packet_capture", name="IPv4 packet capture", version="4.0.0", specialty=Specialty.PACKET, + output_media_types=("text/csv",), description="Captures bounded IPv4 packet metadata without saving packet payloads.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.NETWORK, Capability.PACKET_CAPTURE, Capability.ELEVATED_PRIVILEGES), diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py index 0a57ae3c..4dae7d31 100644 --- a/core/process/detailed_processes.py +++ b/core/process/detailed_processes.py @@ -26,6 +26,7 @@ def metadata(cls) -> CollectorMetadata: name="Detailed running processes", version="4.0.0", specialty=Specialty.PROCESS, + output_media_types=("text/csv",), description="Exports the verbose local Windows task list as CSV.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/process/memory_map.py b/core/process/memory_map.py index b1db55ac..8a42b282 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -41,6 +41,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the bounded memory-region JSON artifact contract.""" return CollectorMetadata( id="core.process.memory_map", name="Process memory map", version="4.0.0", specialty=Specialty.PROCESS, + output_media_types=("application/json",), description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.", author="Logicytics", supported_platforms=("win32",), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), diff --git a/core/process/process_memory.py b/core/process/process_memory.py index f8c0bacd..d91583ca 100644 --- a/core/process/process_memory.py +++ b/core/process/process_memory.py @@ -24,6 +24,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated process-memory JSON artifact contract.""" return CollectorMetadata( id="core.process.process_memory", name="Process memory", version="4.0.0", specialty=Specialty.PROCESS, + output_media_types=("application/json",), description="Exports aggregate working-set, private, and virtual memory counters for local processes.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), timeout_seconds=60, diff --git a/core/process/running_processes.py b/core/process/running_processes.py index 2a3104ec..eea7f7d3 100644 --- a/core/process/running_processes.py +++ b/core/process/running_processes.py @@ -27,6 +27,7 @@ def metadata(cls) -> CollectorMetadata: name="Running processes", version="4.0.0", specialty=Specialty.PROCESS, + output_media_types=("text/csv",), description="Exports the non-verbose Windows Tasklist process inventory as CSV.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index e1e63633..341f5349 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -28,6 +28,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.registry.hklm_backup", name="HKLM registry backup", version="4.0.0", specialty=Specialty.REGISTRY, + output_media_types=("text/plain",), description="Exports the local HKLM hive as a .reg backup after explicit sensitive-data approval.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ, Capability.SUBPROCESS, Capability.SENSITIVE_FILES), diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py index f790409a..0199984d 100644 --- a/core/registry/installed_applications.py +++ b/core/registry/installed_applications.py @@ -71,6 +71,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.registry.installed_applications", name="Installed applications", version="4.0.0", specialty=Specialty.REGISTRY, + output_media_types=("application/json",), description="Exports installed application names, versions, publishers, and install metadata from uninstall keys.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py index e0d94bf2..e9fd432a 100644 --- a/core/registry/startup_applications.py +++ b/core/registry/startup_applications.py @@ -46,6 +46,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.registry.startup_applications", name="Startup applications", version="4.0.0", specialty=Specialty.REGISTRY, + output_media_types=("application/json",), description="Exports standard user and machine Run/RunOnce startup registry entries.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index 3a509608..9d6b88f6 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -20,6 +20,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent SSH archive artifact contract.""" return CollectorMetadata( id="core.ssh.ssh_backup", name="SSH directory backup", version="4.0.0", specialty=Specialty.SSH, + output_media_types=("application/zip",), description="Archives the current user's .ssh keys and configuration after explicit sensitive-data approval.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index a7f6a4b0..2e6864d2 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -64,6 +64,7 @@ def metadata(cls) -> CollectorMetadata: name="Logical drives", version="4.0.0", specialty=Specialty.STORAGE, + output_media_types=("application/json",), description="Records mounted logical-drive types and aggregate capacity metadata.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index 37ad5ca8..2315f55f 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -26,6 +26,7 @@ def metadata(cls) -> CollectorMetadata: name="Mounted volumes", version="4.0.0", specialty=Specialty.STORAGE, + output_media_types=("text/plain",), description="Exports Windows mounted volume GUID and mount-point mappings from mountvol.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index 790455fc..a0c17236 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -27,6 +27,7 @@ def metadata(cls) -> CollectorMetadata: name="Physical disks", version="4.0.0", specialty=Specialty.STORAGE, + output_media_types=("application/json",), description="Exports local physical disk models, media types, interface types, and sizes.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index 37e8a99f..2742557d 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -59,6 +59,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the capability-free detailed volume artifact contract.""" return CollectorMetadata( id="core.storage.volume_details", name="Volume details", version="4.0.0", specialty=Specialty.STORAGE, + output_media_types=("application/json",), description="Exports mounted drive type, filesystem, label, and capacity metadata.", author="Logicytics", supported_platforms=("win32",), default_profiles=("deep",), timeout_seconds=15, maximum_output_bytes=128 * 1024, diff --git a/core/system/bios_info.py b/core/system/bios_info.py index c9232ced..f15b1772 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -65,6 +65,7 @@ def metadata(cls) -> CollectorMetadata: name="BIOS information", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("text/html",), description="Exports BIOS manufacturer, name, version, and release date as HTML.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/computer_system.py b/core/system/computer_system.py index 45cf6dc6..94dc4321 100644 --- a/core/system/computer_system.py +++ b/core/system/computer_system.py @@ -34,6 +34,7 @@ def metadata(cls) -> CollectorMetadata: name="Computer system", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports local computer model, manufacturer, and processor-count CIM data.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/defender_status.py b/core/system/defender_status.py index c1ab5acc..173a4ab7 100644 --- a/core/system/defender_status.py +++ b/core/system/defender_status.py @@ -32,6 +32,7 @@ def metadata(cls) -> CollectorMetadata: name="Microsoft Defender status", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports installed Microsoft Defender engine, signature, and protection-state metadata.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index 4542ab55..bb157e25 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -25,6 +25,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.system.environment_posture", name="Environment posture", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports administrator state, UAC settings, and PowerShell execution policies.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), default_profiles=("deep",), diff --git a/core/system/group_policy.py b/core/system/group_policy.py index e5163062..8a4afdf2 100644 --- a/core/system/group_policy.py +++ b/core/system/group_policy.py @@ -33,6 +33,7 @@ def metadata(cls) -> CollectorMetadata: name="Group policy result", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("text/plain",), description="Exports the local Windows Group Policy Result summary through gpresult.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index 7e65a201..74bc7794 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -33,6 +33,7 @@ def metadata(cls) -> CollectorMetadata: name="Installed drivers", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("text/csv",), description="Exports detailed Windows driver inventory information as CSV.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index a7861f21..d7dcdea7 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -25,6 +25,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.system.installed_updates", name="Installed Windows updates", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports local installed hotfix identifiers, descriptions, and install dates.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/system/local_accounts.py b/core/system/local_accounts.py index d7ff1111..bb74db55 100644 --- a/core/system/local_accounts.py +++ b/core/system/local_accounts.py @@ -27,6 +27,7 @@ def metadata(cls) -> CollectorMetadata: name="Local accounts", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports local account names, SIDs, enabled state, descriptions, and login timestamps only.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/operating_system.py b/core/system/operating_system.py index 771d2c1c..fef0e330 100644 --- a/core/system/operating_system.py +++ b/core/system/operating_system.py @@ -34,6 +34,7 @@ def metadata(cls) -> CollectorMetadata: name="Operating system details", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports detailed local Windows operating-system CIM information.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/scheduled_tasks.py b/core/system/scheduled_tasks.py index 31bac5db..22ba73ba 100644 --- a/core/system/scheduled_tasks.py +++ b/core/system/scheduled_tasks.py @@ -24,6 +24,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated scheduled-task JSON artifact contract.""" return CollectorMetadata( id="core.system.scheduled_tasks", name="Scheduled tasks", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports up to 1,000 local scheduled-task names, paths, authors, descriptions, and states.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py index e32258b6..d36b4e63 100644 --- a/core/system/session_snapshot.py +++ b/core/system/session_snapshot.py @@ -27,6 +27,7 @@ def metadata(cls) -> CollectorMetadata: name="Session snapshot", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports current user/SID, OS build, memory, language, host, time, and system-drive data.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/system_details.py b/core/system/system_details.py index 6db09033..07be8196 100644 --- a/core/system/system_details.py +++ b/core/system/system_details.py @@ -33,6 +33,7 @@ def metadata(cls) -> CollectorMetadata: name="System details", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("text/plain",), description="Exports the complete Windows systeminfo report as text.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index 2ba48065..a457b206 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -25,6 +25,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.system.system_diagnostics", name="System diagnostics", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports architecture, CPU, page-size, and boot-time diagnostics through Windows CIM.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), default_profiles=("deep",), diff --git a/core/system/system_info.py b/core/system/system_info.py index 7db1270c..fe75c24a 100644 --- a/core/system/system_info.py +++ b/core/system/system_info.py @@ -24,6 +24,7 @@ def metadata(cls) -> CollectorMetadata: name="System information", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Collects a bounded operating-system and hardware inventory.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/system/windows_services.py b/core/system/windows_services.py index 57891969..60e6b371 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -24,6 +24,7 @@ def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Windows-services JSON artifact contract.""" return CollectorMetadata( id="core.system.windows_services", name="Windows services", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/json",), description="Exports local Windows service names, states, start modes, accounts, and executable paths.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index 02529a5a..c1c0d72e 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -21,6 +21,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.system.windows_system_data_backup", name="Windows system-data backup", version="4.0.0", specialty=Specialty.SYSTEM, + output_media_types=("application/octet-stream",), description="Copies bounded Group Policy, event-log, and Windows security-support evidence.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), diff --git a/core/usb/usb_storage_inventory.py b/core/usb/usb_storage_inventory.py index ddff1412..a57c5b33 100644 --- a/core/usb/usb_storage_inventory.py +++ b/core/usb/usb_storage_inventory.py @@ -83,6 +83,7 @@ def metadata(cls) -> CollectorMetadata: name="USB storage inventory", version="4.0.0", specialty=Specialty.USB, + output_media_types=("application/json",), description="Reads USB storage device class, instance ID, friendly name, and last-write time from USBSTOR.", author="Logicytics", supported_platforms=("win32",), diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py index 20ed9612..ed0862a6 100644 --- a/core/wireless/wifi_interfaces.py +++ b/core/wireless/wifi_interfaces.py @@ -34,6 +34,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.wireless.wifi_interfaces", name="Wi-Fi interfaces", version="4.0.0", specialty=Specialty.WIRELESS, + output_media_types=("application/json",), description="Exports local Wi-Fi interface state and normalized interface names without key material.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_configuration",), default_profiles=("deep",), diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index a1db6e7e..2569c4d6 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -29,6 +29,7 @@ def metadata(cls) -> CollectorMetadata: return CollectorMetadata( id="core.wireless.wifi_profile_keys", name="Saved Wi-Fi profile keys", version="4.0.0", specialty=Specialty.WIRELESS, + output_media_types=("application/xml",), description="Exports saved Wi-Fi profile XML including key material after explicit sensitive-data approval.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS, Capability.SENSITIVE_FILES), diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py index d9206e1d..abdde3ea 100644 --- a/core/wireless/wifi_profiles.py +++ b/core/wireless/wifi_profiles.py @@ -26,6 +26,7 @@ def metadata(cls) -> CollectorMetadata: name="Saved Wi-Fi profiles", version="4.0.0", specialty=Specialty.WIRELESS, + output_media_types=("text/plain",), description="Exports local saved Wi-Fi profile names without retrieving key material.", author="Logicytics", supported_platforms=("win32",), diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 6b6bc15a..8d322f00 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -434,7 +434,8 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat allowed = { "metadata", "validate", "prepare", "collect", "finalize", "cleanup", "estimate", "dependencies", } - if candidate.kind is CollectorKind.PLUGIN and "metadata" in methods: + metadata_calls: list[ast.Call] = [] + if "metadata" in methods: metadata_calls = [ node for node in ast.walk(methods["metadata"]) if isinstance(node, ast.Call) @@ -443,20 +444,64 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat or isinstance(node.func, ast.Attribute) and node.func.attr == "CollectorMetadata" ) ] + if len(metadata_calls) != 1: + candidate.static_errors.append( + "metadata must construct one CollectorMetadata object directly" + ) + if candidate.kind is CollectorKind.PLUGIN and "metadata" in methods: required_plugin_fields = { "capabilities", "privilege_level", "sensitive_data_categories", "network_access", "estimated_cost", "timeout_seconds", "maximum_output_bytes", "output_media_types", "minimum_contract_version", } - if len(metadata_calls) != 1: - candidate.static_errors.append("plugin metadata must construct one CollectorMetadata object directly") - else: + if len(metadata_calls) == 1: declared_fields = {keyword.arg for keyword in metadata_calls[0].keywords if keyword.arg is not None} missing_fields = sorted(required_plugin_fields - declared_fields) if missing_fields: candidate.static_errors.append( f"plugin metadata must explicitly declare: {', '.join(missing_fields)}" ) + if "collect" in methods and len(metadata_calls) == 1: + declared_keyword = next( + (keyword for keyword in metadata_calls[0].keywords if keyword.arg == "output_media_types"), + None, + ) + if declared_keyword is None: + candidate.static_errors.append("metadata must explicitly declare output_media_types") + else: + try: + declared_media_types = set(ast.literal_eval(declared_keyword.value)) + except (TypeError, ValueError): + candidate.static_errors.append("metadata output_media_types must be a literal tuple") + else: + registered_media_types: set[str] = set() + lifecycle_methods = [methods["collect"]] + if "finalize" in methods: + lifecycle_methods.append(methods["finalize"]) + for method in lifecycle_methods: + for call in ast.walk(method): + if not isinstance(call, ast.Call) or not ( + isinstance(call.func, ast.Attribute) and call.func.attr == "register_file" + ): + continue + media_keyword = next( + (keyword for keyword in call.keywords if keyword.arg == "media_type"), + None, + ) + if media_keyword is None: + registered_media_types.add("application/octet-stream") + elif isinstance(media_keyword.value, ast.Constant) and isinstance( + media_keyword.value.value, str + ): + registered_media_types.add(media_keyword.value.value) + else: + candidate.static_errors.append( + f"register_file media_type must be a literal string (line {call.lineno})" + ) + if not registered_media_types.issubset(declared_media_types): + candidate.static_errors.append( + "metadata output_media_types must include every registered artifact type" + ) unknown = sorted(set(methods) - allowed) if unknown: candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 9b37fe91..b83576d3 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -493,6 +493,18 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu raise TypeError("collect() must return CollectorResult") if result.artifacts != writer.artifacts: raise TypeError("collector result artifacts must exactly match registered artifacts") + undeclared_media_types = sorted( + { + artifact.media_type + for artifact in result.artifacts + if artifact.media_type not in metadata.output_media_types + } + ) + if undeclared_media_types: + raise TypeError( + "collector registered undeclared output media types: " + f"{', '.join(undeclared_media_types)}" + ) result = collector.finalize(context, result) if not isinstance(result, CollectorResult): raise TypeError("finalize() must return CollectorResult") diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 36c210cd..aa889407 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -83,6 +83,7 @@ def metadata(cls) -> CollectorMetadata: name="System info", version="1.0.0", specialty=Specialty.SYSTEM, + output_media_types=("text/plain",), description="Creates a harmless text artifact for core tests.", author="tests", supported_platforms=("win32",), @@ -116,7 +117,6 @@ def _plugin_collector_source() -> str: ' estimated_cost=EstimatedCost.LOW,\n' ' timeout_seconds=60,\n' ' maximum_output_bytes=100 * 1024 * 1024,\n' - ' output_media_types=("text/plain",),\n' ' minimum_contract_version="4.0",', ) @@ -924,6 +924,37 @@ def test_custom_specialty_is_plugin_only(self) -> None: with self.assertRaises(ValueError): CollectorMetadata.from_dict(metadata) + def test_preflight_requires_exact_declared_artifact_media_types(self) -> None: + """A collector cannot disguise or omit the output contract used by registration.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text( + _COLLECTOR.replace( + 'output_media_types=("text/plain",)', + 'output_media_types=("application/json",)', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertTrue( + any("include every" in error for error in report.invalid[0].static_errors), + report.invalid[0].static_errors, + ) + + collector_path.write_text( + _COLLECTOR.replace(' output_media_types=("text/plain",),\n', ""), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertTrue( + any("explicitly declare" in error for error in report.invalid[0].static_errors), + report.invalid[0].static_errors, + ) + def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: """Collector metadata must be a complete typed declaration rather than free text.""" common = dict( @@ -2235,6 +2266,9 @@ def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) source = _COLLECTOR.replace( "CollectorResult, CoreCollector, Specialty", "CollectorResult, CoreCollector, EvidenceKind, Specialty", + ).replace( + 'output_media_types=("text/plain",)', + 'output_media_types=("text/plain", "application/octet-stream")', ).replace( ' return CollectorResult.succeeded("test artifact created", (artifact,))', ' raw = context.workspace / "source.bin"\n' From 0b816677181ed4c7090f003ef44f07e17e335073 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 18:02:33 +0300 Subject: [PATCH 176/316] feat: add verified artifact opening Expose an explicit Windows artifact-opening API that validates canonical identity, run ownership, file size, and SHA-256 with streaming reads before invoking the associated application. Document the public helper, cover successful and tampered artifact behavior, and reconcile the already verified run-specific temporary workspace cleanup requirement. --- README.md | 6 +++- TODO.md | 4 +-- logicytics/__init__.py | 3 +- logicytics/api.py | 48 ++++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 18 +++++++++++- 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index eca57f01..44c0e840 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ public names are requested: ```python from pathlib import Path -from logicytics import RunRequest, load_configuration, plan_run, query_run, read_artifact, run_collection +from logicytics import RunRequest, load_configuration, open_artifact, plan_run, query_run, read_artifact, run_collection root = Path.cwd() configuration = load_configuration(root) @@ -169,6 +169,8 @@ for collector in snapshot.collectors: if collector.failure is not None: print(collector.failure.operation, collector.failure.remediation) contents = read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration) +# Explicit user-facing actions may open the verified artifact with its Windows handler: +# open_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration) ``` Planning enforces strict core/plugin preflight and configured worker bounds @@ -179,6 +181,8 @@ run summary prints the same redacted per-collector status, duration, summary, and failure guidance. Artifact reads are bounded (16 MiB by default, never more than 64 MiB) and verify the exact registered bytes against their manifest SHA-256 before returning evidence. +`open_artifact` applies the same run ownership and hash checks with streaming I/O +before asking Windows to open the file through its associated application. ## Configuration diff --git a/TODO.md b/TODO.md index dc21fc10..5345c2b7 100644 --- a/TODO.md +++ b/TODO.md @@ -689,8 +689,8 @@ Windows platform allows. - [x] Name packages using the action and timestamp. - [x] Generate a SHA-256 digest for each package and move both artifacts into the output/data output directories. -- [ ] Provide a file-opening helper for viewing generated artifacts. -- [ ] Implement the v4.0 temporary-workspace lifecycle: collectors write into a +- [x] Provide a file-opening helper for viewing generated artifacts. +- [x] Implement the v4.0 temporary-workspace lifecycle: collectors write into a run-specific temporary directory, the packager consumes that directory, and cleanup occurs after successful or interrupted runs. diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 7c22e4c6..3766d285 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -23,7 +23,7 @@ ) _APPLICATION_EXPORTS = frozenset({ - "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "plan_run", "query_run", "read_artifact", + "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "open_artifact", "plan_run", "query_run", "read_artifact", "run_collection", }) @@ -61,6 +61,7 @@ def __getattr__(name: str): "Specialty", "ValidationResult", "load_configuration", + "open_artifact", "plan_run", "query_run", "read_artifact", diff --git a/logicytics/api.py b/logicytics/api.py index c7b98865..5aaf35a8 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import os import json import re from dataclasses import dataclass @@ -373,3 +374,50 @@ def read_artifact( ): raise ArtifactError("registered artifact failed manifest size or SHA-256 verification") return contents + + +def open_artifact( + project_root: Path | str, + run_id: str, + artifact_id: str, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, +) -> Path: + """Verify and open one registered artifact with the platform's associated application.""" + if not isinstance(artifact_id, str) or _ARTIFACT_ID.fullmatch(artifact_id) is None: + raise ArtifactError("artifact_id must be a canonical registered artifact identifier") + snapshot = query_run( + project_root, + run_id, + configuration=configuration, + config_path=config_path, + ) + artifact = next((item for item in snapshot.artifacts if item.id == artifact_id), None) + if artifact is None: + raise ArtifactError("artifact is not registered in the selected run manifest") + root = snapshot.run_directory / "artifacts" + source = root.joinpath(*PurePosixPath(artifact.relative_path).parts) + owner = root / artifact.collector_id.replace(".", "_") + try: + if root.resolve(strict=True) != root or owner.resolve(strict=True) != owner: + raise ValueError("artifact ownership roots must not be redirected") + resolved = source.resolve(strict=True) + resolved.relative_to(owner) + if not source.is_file() or source.stat().st_size != artifact.size_bytes: + raise ArtifactError("registered artifact size does not match its manifest") + digest = hashlib.sha256() + with source.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + except (OSError, ValueError) as error: + raise ArtifactError( + "registered artifact escapes its collector-owned store or cannot be opened" + ) from error + if digest.hexdigest() != artifact.sha256: + raise ArtifactError("registered artifact failed manifest SHA-256 verification") + platform_opener = getattr(os, "startfile", None) + if platform_opener is None: + raise OSError("opening artifacts requires a Windows associated-file handler") + platform_opener(source) + return source diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index aa889407..1226c1ca 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -20,7 +20,14 @@ from unittest.mock import MagicMock, patch from logicytics import packaging -from logicytics import load_configuration, plan_run, query_run, read_artifact, run_collection +from logicytics import ( + load_configuration, + open_artifact, + plan_run, + query_run, + read_artifact, + run_collection, +) from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command from logicytics.cli import _parser, _request, main @@ -864,6 +871,13 @@ def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_ac original = stored.read_bytes() manifest_source = outcome.manifest_path.read_text(encoding="utf-8") + with patch("logicytics.api.os.startfile") as startfile: + self.assertEqual( + stored, + open_artifact(root, outcome.manifest.run_id, artifact.id), + ) + startfile.assert_called_once_with(stored) + with self.assertRaisesRegex(ArtifactError, "canonical"): read_artifact(root, outcome.manifest.run_id, "../outside") with self.assertRaisesRegex(ArtifactError, "not registered"): @@ -877,6 +891,8 @@ def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_ac stored.write_bytes(b"bad") with self.assertRaisesRegex(ArtifactError, "SHA-256 verification"): read_artifact(root, outcome.manifest.run_id, artifact.id) + with self.assertRaisesRegex(ArtifactError, "size"): + open_artifact(root, outcome.manifest.run_id, artifact.id) stored.write_bytes(original) payload = json.loads(manifest_source) payload["artifact_catalog"][0]["producer_status"] = "failed" From a15bbc13f2785f1ccb9f2af97d574d7679754713 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 29 Aug 2026 18:04:04 +0300 Subject: [PATCH 177/316] feat: add an explicit final run prompt Allow interactive runs to pause after the final manifest and status are printed while preserving prompt-free behavior for automation and ordinary command execution. Document and test the opt-in prompt, then mark the completed main-entry lifecycle requirements in the recreation checklist. --- README.md | 2 ++ TODO.md | 4 ++-- logicytics/cli.py | 13 ++++++++++++- tests/test_core_functionality.py | 9 +++++++-- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 44c0e840..9e253783 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ ZIP package and SHA-256 sidecar use the action, UTC request timestamp, and run ID, and are written into that run's `packages/` and `hashes/` directories; collector workspaces and `logs/` remain run-scoped. Explicit reruns use a `rerun--run-.zip` package identity. +Add `--interactive` to `run` when a transient command window should pause on the +final status; noninteractive and automated runs never prompt. `debug` writes redacted environment, preflight, Sysinternals, Python-support, version, and configured file-integrity diagnostics to diff --git a/TODO.md b/TODO.md index 5345c2b7..17cf46e7 100644 --- a/TODO.md +++ b/TODO.md @@ -556,7 +556,7 @@ Windows platform allows. ## 1. Main engine and execution lifecycle -- [ ] Provide a single main entry point that: +- [x] Provide a single main entry point that: - [x] Parses command-line flags and validates their combinations. - [x] Handles special actions before collection starts. - [x] Checks privileges and relevant Windows prerequisites. @@ -571,7 +571,7 @@ Windows platform allows. - [x] Produces a SHA-256 hash beside every package. - [x] Performs the requested shutdown or reboot action at the end. - [x] Handles Ctrl+C gracefully and attempts final packaging/cleanup. - - [ ] Offers a final exit prompt for interactive use. + - [x] Offers a final exit prompt for interactive use. - [x] Discover runnable files recursively from canonical `core/`, `plugins/`, and `MODS/` directories; the legacy CODE directory is intentionally retired. - [x] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. diff --git a/logicytics/cli.py b/logicytics/cli.py index 57f037cd..136e0eee 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -205,6 +205,11 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Confirm you are authorized to collect the selected evidence.", ) + subparser.add_argument( + "--interactive", + action="store_true", + help="Pause at the final status so an interactive command window remains visible.", + ) if command == "update": subparser.add_argument("--apply", action="store_true", help="Explicitly run git pull after repository checks.") @@ -428,7 +433,13 @@ def main(argv: list[str] | None = None) -> int: f"SHA-256: {outcome.manifest.package.get('sha256_path', 'unavailable')}" ) print(f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}") - return 0 if outcome.manifest.status.value == "succeeded" else 1 + exit_code = 0 if outcome.manifest.status.value == "succeeded" else 1 + if arguments.interactive: + try: + input("Press Enter to exit...") + except EOFError: + pass + return exit_code except (LogicyticsError, OSError, PermissionError, ValueError) as error: print(f"Error: {error}") return 2 diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 1226c1ca..983013dd 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -764,9 +764,14 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat collector_path.write_text(_COLLECTOR, encoding="utf-8") output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): - exit_code = main(["run", "--default", "--acknowledge-authorization"]) + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", output + ), patch("builtins.input", return_value="") as final_prompt: + exit_code = main( + ["run", "--default", "--interactive", "--acknowledge-authorization"] + ) self.assertEqual(0, exit_code, output.getvalue()) + final_prompt.assert_called_once_with("Press Enter to exit...") self.assertIn("Collectors:", output.getvalue()) self.assertIn("core.system.system_info status=succeeded duration_seconds=", output.getvalue()) self.assertIn("summary=test artifact created", output.getvalue()) From 383a6055d434af5a0d88ac29dff9774442c2b0ac Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 00:14:48 +0300 Subject: [PATCH 178/316] feat: add bounded application logging Introduce typed logging configuration with seven supported levels, colored console output, redacted UTC text rows, raw and batched dispatch, retention, explicit previous-log deletion, and bounded truncation. Create the canonical global output tree and connect one process-wide application logger to CLI and supervised run lifecycle events while retaining isolated structured JSONL logs for each run and collector. Document output and retention behavior, add configuration and sink contract coverage, and mark the verified logging, output-layout, configuration, and maintenance phase requirements complete. --- README.md | 22 +++++ TODO.md | 28 +++---- logicytics/cli.py | 19 ++++- logicytics/configuration.py | 68 ++++++++++++++- logicytics/logging.py | 140 ++++++++++++++++++++++++++++++- logicytics/output_layout.py | 50 +++++++++++ logicytics/runtime.py | 33 +++++++- tests/test_core_functionality.py | 118 +++++++++++++++++++++++++- 8 files changed, 457 insertions(+), 21 deletions(-) create mode 100644 logicytics/output_layout.py diff --git a/README.md b/README.md index 9e253783..bb1c345c 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,13 @@ plugins/ # user-created collectors, validated separately logicytics/ # engine, contracts, planning, runtime, and packaging tests/ # core integration tests output/ # ignored generated evidence and diagnostics + logs/ + Logicytics.log # bounded redacted application log + debug/ # debug-action diagnostics + performance/ # stable performance-report location data/ + zip/ # stable package-output location + hashes/ # stable package-hash location run-/ # one self-contained run output tree artifacts/ # collector-owned registered evidence store collectors/ # private collector workspaces and event channels @@ -200,6 +206,15 @@ engine-wide `runtime` options from per-collector `collectors` settings: "minimum_python": "3.11", "recommended_python": "3.11" }, + "logging": { + "level": "INFO", + "console_enabled": true, + "color_enabled": true, + "file_enabled": true, + "maximum_bytes": 4194304, + "delete_previous": false, + "retention_days": 30 + }, "collectors": { "core.filesystem.system_drive_tree": {"max_entries": 5000, "max_depth": 12}, "core.process.memory_map": {"max_regions": 5000, "dump_directory": "memory_maps"} @@ -219,6 +234,13 @@ manifests can contain only a schema version, project version, and file hashes; they cannot configure, enable, or add collectors, and collection never depends on the remote endpoint. +Application logging supports `DEBUG`, `INTERNAL`, `INFO`, `WARNING`, `ERROR`, +`EXCEPTION`, and `CRITICAL`. Human-readable UTC rows are redacted before they +reach colored stderr or `output/logs/Logicytics.log`; per-run and per-collector +JSONL logs remain isolated in each run. The configured byte limit retains recent +complete rows, `delete_previous` removes the prior application log explicitly, +and `retention_days` removes expired rotated application logs. + Schema-version `3` JSON files are migrated in memory without modifying the source file. Legacy `workers`/`worker_count`, `max_workers`, `output_root`, and `collector_settings` aliases become their v4 runtime or collector equivalents; diff --git a/TODO.md b/TODO.md index 17cf46e7..3689e945 100644 --- a/TODO.md +++ b/TODO.md @@ -609,7 +609,7 @@ Windows platform allows. - [x] Support side actions: `debug`, `update`, and `dev`. - [x] Provide action/sub-action exclusivity validation and clear invalid-combination errors. -- [ ] Provide colored console output for status, warnings, errors, and prompts. +- [x] Provide colored console output for status, warnings, errors, and prompts. - [x] Provide a semantic flag-matching feature that can map natural-language user input to the closest command-line flag. - [x] Match input against both flag names and their descriptions. @@ -655,20 +655,20 @@ Windows platform allows. ### Configuration - [ ] Load `config.ini` from the project CODE directory. -- [ ] Expose debug level, version, current file manifest, log retention, worker +- [x] Expose debug level, version, current file manifest, log retention, worker count, preference persistence, and all collector settings. - [x] Support remote configuration retrieval for update/integrity checks. -- [ ] Keep configuration parsing available to every collector through the package. +- [x] Keep configuration parsing available to every collector through the package. ### Logging - [ ] Provide a singleton logger shared by the engine and collectors. -- [ ] Log to console with configurable colors and levels. -- [ ] Log to output/logs/Logicytics.log. -- [ ] Support DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, and EXCEPTION levels. -- [ ] Include timestamps and structured, readable log rows. -- [ ] Support log truncation controls and optional deletion of the previous log. -- [ ] Provide raw logging, newline separators, typed message dispatch, and batched +- [x] Log to console with configurable colors and levels. +- [x] Log to output/logs/Logicytics.log. +- [x] Support DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, and EXCEPTION levels. +- [x] Include timestamps and structured, readable log rows. +- [x] Support log truncation controls and optional deletion of the previous log. +- [x] Provide raw logging, newline separators, typed message dispatch, and batched execution-message parsing. - [x] Provide a function decorator that records function execution and timing. - [x] Provide an exception helper that logs and raises a requested exception type. @@ -678,7 +678,7 @@ Windows platform allows. ### Output and packaging -- [ ] Create output/logs, output/logs/debug, output/logs/performance, +- [x] Create output/logs, output/logs/debug, output/logs/performance, output/data/zip, and output/data/hashes automatically. - [ ] Collect generated files without including source code, executables, model files, configuration secrets, caches, or library internals in the evidence @@ -862,7 +862,7 @@ appropriate default/deep/non-Python mode. ## 7. Configuration and output contract -- [ ] Recreate general settings for debug logging, old-log deletion, worker count, +- [x] Recreate general settings for debug logging, old-log deletion, worker count, and preference-history persistence. - [x] Recreate flag-matching settings for model name, minimum accuracy, and model debug output. @@ -883,7 +883,7 @@ appropriate default/deep/non-Python mode. ### Phase 1 — Foundation - [x] Define the v4.0 package layout and run manifest format. -- [ ] Implement configuration loading, structured logging, file discovery, command +- [x] Implement configuration loading, structured logging, file discovery, command execution, privilege checks, cancellation, and error aggregation. - [ ] Implement the temporary run directory and cleanup lifecycle. - [x] Rename ACCESS folder to output/ and LOGS/ to logs/, PACKAGES/ to data/ (and remove RUNS/ as it should be the same as data/) @@ -912,7 +912,7 @@ appropriate default/deep/non-Python mode. ### Phase 5 — Maintenance and release readiness -- [ ] Implement debugger, update, developer checks, manifest comparison, and +- [x] Implement debugger, update, developer checks, manifest comparison, and version handling. - [ ] Add per-collector tests using mocked Windows command/registry/WMI responses. - [ ] Add Windows integration tests for each output contract. @@ -936,7 +936,7 @@ appropriate default/deep/non-Python mode. - [x] A cancelled run leaves a recoverable partial-run report and cleans only its own temporary data. - [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. -- [ ] Debug/update/developer tools are separated from normal collection behavior. +- [x] Debug/update/developer tools are separated from normal collection behavior. - [ ] The final v4.0 release is documented as a complete recreation of the feature surface listed here. diff --git a/logicytics/cli.py b/logicytics/cli.py index 136e0eee..d50596e9 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import contextlib import importlib.util import json import os @@ -18,6 +19,7 @@ from logicytics.errors import LogicyticsError from logicytics.manifest import MANIFEST_SCHEMA_VERSION from logicytics.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph +from logicytics.logging import get_application_logger from logicytics.maintenance import ( build_manifest, compare_files, @@ -28,6 +30,7 @@ write_local_manifest, ) from logicytics.planner import BUILTIN_PROFILES, build_plan +from logicytics.output_layout import ensure_output_layout from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -328,8 +331,15 @@ def main(argv: list[str] | None = None) -> int: parser.print_help() return 0 root = _project_root() + application_logger = None try: configuration = load_config(root, arguments.config) + layout = ensure_output_layout(configuration.runtime.output_root) + application_logger = get_application_logger( + layout.application_log, + configuration.logging, + ) + application_logger.event("INFO", "command_started", command=arguments.command) history_path = configuration.runtime.output_root / "interaction_history.json.gz" if arguments.command == "match": history = load_history(history_path) @@ -384,7 +394,7 @@ def main(argv: list[str] | None = None) -> int: "preflight": {"valid_collectors": len(report.valid), "invalid_collectors": len(report.invalid)}, "maintenance": maintenance_diagnostics(root, configuration.maintenance), } - debug_path = configuration.runtime.output_root.parent / "logs" / "debug" / "debug.json" + debug_path = layout.debug_logs / "debug.json" payload["debug_log"] = str(debug_path) _write_json(debug_path, payload) print(json.dumps(payload, indent=2, sort_keys=True)) @@ -441,6 +451,13 @@ def main(argv: list[str] | None = None) -> int: pass return exit_code except (LogicyticsError, OSError, PermissionError, ValueError) as error: + if application_logger is not None: + with contextlib.suppress(OSError): + application_logger.event( + "EXCEPTION", + str(error), + error_type=type(error).__name__, + ) print(f"Error: {error}") return 2 diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 5f10f0ad..a3043062 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -21,7 +21,9 @@ r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|(?:plugin|mod)\.[a-z][a-z0-9_]*)$" ) _SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") -_ROOT_FIELDS = frozenset({"schema_version", "runtime", "interaction", "maintenance", "collectors"}) +_ROOT_FIELDS = frozenset({ + "schema_version", "runtime", "interaction", "maintenance", "logging", "collectors", +}) _RUNTIME_FIELDS = frozenset({ "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", }) @@ -30,6 +32,11 @@ "remote_manifest_url", "remote_manifest_sha256", "local_manifest_path", "minimum_python", "recommended_python", }) +_LOGGING_FIELDS = frozenset({ + "level", "console_enabled", "color_enabled", "file_enabled", "maximum_bytes", + "delete_previous", "retention_days", +}) +_LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION"}) _LEGACY_ROOT_FIELDS = _ROOT_FIELDS.union({ "collector_settings", "workers", "worker_count", "max_workers", "output_root", "package_completed_runs", "maximum_run_output_bytes", @@ -193,6 +200,7 @@ def _migrate_v3_configuration(raw: Mapping[str, Any], project_root: Path) -> dic "runtime": runtime, "interaction": raw.get("interaction", {}), "maintenance": raw.get("maintenance", {}), + "logging": raw.get("logging", {}), "collectors": collectors, } @@ -229,6 +237,19 @@ class MaintenanceSettings: recommended_python: str = "3.11" +@dataclass(frozen=True, slots=True) +class LoggingSettings: + """Process-wide human log and console presentation policy.""" + + level: str = "INFO" + console_enabled: bool = True + color_enabled: bool = True + file_enabled: bool = True + maximum_bytes: int = 4 * 1024 * 1024 + delete_previous: bool = False + retention_days: int = 30 + + @dataclass(frozen=True, slots=True) class AppConfig: """Validated settings loaded from an optional JSON configuration file.""" @@ -237,6 +258,7 @@ class AppConfig: runtime: RuntimeSettings interaction: InteractionSettings = field(default_factory=InteractionSettings) maintenance: MaintenanceSettings = field(default_factory=MaintenanceSettings) + logging: LoggingSettings = field(default_factory=LoggingSettings) collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) migrated_from_schema: int | None = None @@ -404,6 +426,41 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi ): raise PlanError("recommended_python must not be older than minimum_python") + logging_raw = raw.get("logging", {}) + if not isinstance(logging_raw, dict): + raise PlanError("logging configuration must be an object") + unknown_logging = sorted(set(logging_raw) - _LOGGING_FIELDS) + if unknown_logging: + raise PlanError( + f"logging configuration contains unsupported settings: {', '.join(unknown_logging)}" + ) + logging_level = logging_raw.get("level", "INFO") + if not isinstance(logging_level, str) or logging_level.upper() not in _LOG_LEVELS: + raise PlanError("logging level must be DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, or EXCEPTION") + console_enabled = logging_raw.get("console_enabled", True) + color_enabled = logging_raw.get("color_enabled", True) + file_enabled = logging_raw.get("file_enabled", True) + delete_previous = logging_raw.get("delete_previous", False) + if not all( + isinstance(value, bool) + for value in (console_enabled, color_enabled, file_enabled, delete_previous) + ): + raise PlanError("logging enable, color, file, and deletion settings must be boolean") + log_maximum_bytes = logging_raw.get("maximum_bytes", 4 * 1024 * 1024) + if ( + not isinstance(log_maximum_bytes, int) + or isinstance(log_maximum_bytes, bool) + or not 1024 <= log_maximum_bytes <= 64 * 1024 * 1024 + ): + raise PlanError("logging maximum_bytes must be an integer from 1024 to 67108864") + retention_days = logging_raw.get("retention_days", 30) + if ( + not isinstance(retention_days, int) + or isinstance(retention_days, bool) + or not 0 <= retention_days <= 3650 + ): + raise PlanError("logging retention_days must be an integer from 0 to 3650") + collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( isinstance(key, str) and isinstance(value, dict) @@ -433,6 +490,15 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi minimum_python=minimum_python, recommended_python=recommended_python, ), + logging=LoggingSettings( + level=logging_level.upper(), + console_enabled=console_enabled, + color_enabled=color_enabled, + file_enabled=file_enabled, + maximum_bytes=log_maximum_bytes, + delete_previous=delete_previous, + retention_days=retention_days, + ), collector_settings=collector_settings, migrated_from_schema=migrated_from_schema, ) diff --git a/logicytics/logging.py b/logicytics/logging.py index 4f9fa7b4..9b3329e7 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -3,18 +3,154 @@ from __future__ import annotations import json +import sys import traceback from datetime import datetime, timezone from pathlib import Path from threading import RLock -from time import perf_counter -from typing import Callable, ParamSpec, TypeVar +from time import perf_counter, time +from typing import Callable, Iterable, ParamSpec, TextIO, TypeVar +from logicytics.configuration import LoggingSettings from logicytics.contracts import EventLogger from logicytics.redaction import redact_mapping, redact_text Parameters = ParamSpec("Parameters") Result = TypeVar("Result") +_LEVEL_ORDER = { + "DEBUG": 10, + "INTERNAL": 15, + "INFO": 20, + "WARNING": 30, + "ERROR": 40, + "EXCEPTION": 45, + "CRITICAL": 50, +} +_LEVEL_COLORS = { + "DEBUG": "\033[36m", + "INTERNAL": "\033[35m", + "INFO": "\033[32m", + "WARNING": "\033[33m", + "ERROR": "\033[31m", + "EXCEPTION": "\033[91m", + "CRITICAL": "\033[97;41m", +} +_LOGGER_LOCK = RLock() +_APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} + + +class ApplicationLogger(EventLogger): + """Thread-safe human-readable file and colored-console event sink.""" + + def __init__( + self, + path: Path, + settings: LoggingSettings, + *, + console: TextIO | None = None, + ) -> None: + self.path = path.resolve() + self.settings = settings + self.console = sys.stderr if console is None else console + self._lock = RLock() + self.path.parent.mkdir(parents=True, exist_ok=True) + self._prepare_file() + + def _prepare_file(self) -> None: + """Apply explicit deletion, retention, and bounded truncation policies.""" + if self.settings.delete_previous and self.path.exists(): + self.path.unlink() + cutoff = time() - self.settings.retention_days * 86400 + for candidate in self.path.parent.glob("Logicytics*.log"): + if ( + candidate != self.path + and candidate.is_file() + and candidate.stat().st_mtime < cutoff + ): + candidate.unlink() + self._truncate_file() + + def _truncate_file(self) -> None: + """Retain the newest complete rows when the configured byte limit is exceeded.""" + if self.path.is_file() and self.path.stat().st_size > self.settings.maximum_bytes: + with self.path.open("rb") as stream: + stream.seek(-self.settings.maximum_bytes // 2, 2) + retained = stream.read() + newline = retained.find(b"\n") + retained = retained[newline + 1:] if newline >= 0 else retained + self.path.write_bytes(b"[log truncated to configured maximum]\n" + retained) + + def event(self, level: str, message: str, **fields: int | float | str) -> None: + """Dispatch one typed, redacted event to configured console and file sinks.""" + normalized = level.upper() + if normalized not in _LEVEL_ORDER: + raise ValueError(f"unsupported log level: {level}") + if _LEVEL_ORDER[normalized] < _LEVEL_ORDER[self.settings.level]: + return + safe_message = redact_text(message) + safe_fields = redact_mapping(fields) + suffix = "" if not safe_fields else " " + " ".join( + f"{key}={json.dumps(value, ensure_ascii=True, sort_keys=True)}" + for key, value in sorted(safe_fields.items()) + ) + timestamp = datetime.now(timezone.utc).isoformat() + row = f"[{timestamp}] [{normalized}] {safe_message}{suffix}" + with self._lock: + if self.settings.file_enabled: + with self.path.open("a", encoding="utf-8") as stream: + stream.write(row + "\n") + self._truncate_file() + if self.settings.console_enabled: + if self.settings.color_enabled and self.console.isatty(): + self.console.write(f"{_LEVEL_COLORS[normalized]}{row}\033[0m\n") + else: + self.console.write(row + "\n") + self.console.flush() + + def raw(self, message: str, *, end: str = "\n") -> None: + """Write redacted raw text while retaining configured sink ownership.""" + if end not in {"", "\n"}: + raise ValueError("raw log end must be empty or a newline") + safe = redact_text(message) + with self._lock: + if self.settings.file_enabled: + with self.path.open("a", encoding="utf-8") as stream: + stream.write(safe + end) + self._truncate_file() + if self.settings.console_enabled: + self.console.write(safe + end) + self.console.flush() + + def separator(self) -> None: + """Write one blank line through the raw logging path.""" + self.raw("") + + def dispatch(self, messages: Iterable[str]) -> None: + """Parse and dispatch a batch of optional `LEVEL: message` rows.""" + for message in messages: + level, separator, text = message.partition(":") + normalized = level.strip().upper() + if separator and normalized in _LEVEL_ORDER: + self.event(normalized, text.strip()) + else: + self.event("INFO", message.strip()) + + +def get_application_logger( + path: Path, + settings: LoggingSettings, + *, + console: TextIO | None = None, +) -> ApplicationLogger: + """Return one configured logger instance per canonical application log path.""" + resolved = path.resolve() + with _LOGGER_LOCK: + logger = _APPLICATION_LOGGERS.get(resolved) + if logger is None or logger.settings != settings or console is not None: + logger = ApplicationLogger(resolved, settings, console=console) + if console is None: + _APPLICATION_LOGGERS[resolved] = logger + return logger class FileEventLogger(EventLogger): diff --git a/logicytics/output_layout.py b/logicytics/output_layout.py new file mode 100644 index 00000000..221f68ef --- /dev/null +++ b/logicytics/output_layout.py @@ -0,0 +1,50 @@ +"""Canonical global output directories shared by CLI maintenance and run actions.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class OutputLayout: + """Resolved global output locations outside individual run directories.""" + + data: Path + logs: Path + debug_logs: Path + performance_logs: Path + packages: Path + hashes: Path + application_log: Path + + +def output_layout(output_root: Path) -> OutputLayout: + """Derive the stable output tree from the configured run-data root.""" + data = output_root.resolve() + output = data.parent + logs = output / "logs" + return OutputLayout( + data=data, + logs=logs, + debug_logs=logs / "debug", + performance_logs=logs / "performance", + packages=data / "zip", + hashes=data / "hashes", + application_log=logs / "Logicytics.log", + ) + + +def ensure_output_layout(output_root: Path) -> OutputLayout: + """Create the complete stable output tree for a state-changing application action.""" + layout = output_layout(output_root) + for directory in ( + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, + ): + directory.mkdir(parents=True, exist_ok=True) + return layout diff --git a/logicytics/runtime.py b/logicytics/runtime.py index b83576d3..3daf8c65 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -42,9 +42,10 @@ ) from logicytics.discovery import CollectorCandidate from logicytics.errors import LogicyticsError -from logicytics.logging import FileEventLogger +from logicytics.logging import FileEventLogger, get_application_logger from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.packaging import package_manifest +from logicytics.output_layout import ensure_output_layout from logicytics.planner import RunPlan @@ -599,6 +600,11 @@ def run(self, plan: RunPlan) -> RunOutcome: ) if plan.request.max_workers > self.configuration.runtime.maximum_workers: raise ValueError("requested workers exceed configured maximum_workers") + output_layout = ensure_output_layout(self.configuration.runtime.output_root) + application_logger = get_application_logger( + output_layout.application_log, + self.configuration.logging, + ) run_id = f"run-{uuid4().hex}" run_directory = self.configuration.runtime.output_root / run_id workspace_root = run_directory / "collectors" @@ -620,6 +626,12 @@ def run(self, plan: RunPlan) -> RunOutcome: manifest.status = RunStatus.RUNNING write_manifest(manifest_path, manifest) run_logger.event("info", "run_started", collectors=len(plan.collectors)) + application_logger.event( + "INFO", + "run_started", + run_id=run_id, + collectors=len(plan.collectors), + ) records = {record.id: record for record in manifest.collectors} try: @@ -655,12 +667,31 @@ def run(self, plan: RunPlan) -> RunOutcome: package_path=str(package_path), hash_path=str(hash_path), ) + application_logger.event( + "INFO", + "run_packaged", + run_id=run_id, + package_path=str(package_path), + ) except (OSError, ValueError, zipfile.BadZipFile) as error: manifest.status = RunStatus.FAILED manifest.package = {"status": "failed", "error": f"{type(error).__name__}: {error}"} run_logger.event("error", "run_packaging_failed", error_type=type(error).__name__) + application_logger.event( + "ERROR", + "run_packaging_failed", + run_id=run_id, + error_type=type(error).__name__, + ) write_manifest(manifest_path, manifest) run_logger.event("info", "run_finished", status=manifest.status.value, artifacts=manifest.total_artifact_bytes) + application_logger.event( + "INFO", + "run_finished", + run_id=run_id, + status=manifest.status.value, + artifacts=manifest.total_artifact_bytes, + ) if plan.request.post_run_action is not PostRunAction.NONE: self._execute_post_run_action(plan.request.post_run_action, manifest, run_logger) return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 983013dd..b24e2a39 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -32,7 +32,14 @@ from logicytics.command_runner import parse_level_messages, run_command from logicytics.cli import _parser, _request, main from logicytics.file_listing import list_files -from logicytics.logging import FileEventLogger, deprecated, raise_logged, timed +from logicytics.logging import ( + ApplicationLogger, + FileEventLogger, + deprecated, + get_application_logger, + raise_logged, + timed, +) from logicytics.interaction import load_history, match_flag, usage_statistics from logicytics.manifest import write_manifest from logicytics.maintenance import ( @@ -44,7 +51,12 @@ write_local_manifest, ) from logicytics.sysinternals import ensure_sysinternals -from logicytics.configuration import MaintenanceSettings, default_config, load_config +from logicytics.configuration import ( + LoggingSettings, + MaintenanceSettings, + default_config, + load_config, +) from logicytics.contracts import ( Artifact, Capability, @@ -66,6 +78,7 @@ from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError from logicytics.packaging import package_run +from logicytics.output_layout import ensure_output_layout from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor @@ -198,6 +211,107 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): + def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: + """The application sink is typed, redacted, reusable, colored, and size bounded.""" + class TerminalBuffer(io.StringIO): + def isatty(self) -> bool: + return True + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "output" / "logs" / "Logicytics.log" + path.parent.mkdir(parents=True) + path.write_text("previous secret\n", encoding="utf-8") + old = path.parent / "Logicytics-old.log" + old.write_text("expired\n", encoding="utf-8") + os.utime(old, (0, 0)) + console = TerminalBuffer() + settings = LoggingSettings( + level="DEBUG", + console_enabled=True, + color_enabled=True, + maximum_bytes=1024, + delete_previous=True, + retention_days=1, + ) + logger = ApplicationLogger(path, settings, console=console) + for level in ( + "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION" + ): + logger.event(level, "typed event", password="hidden", sequence=1) + logger.dispatch(("WARNING: parsed warning", "plain batch row")) + logger.raw("raw access_token=hidden") + logger.separator() + for index in range(40): + logger.event("INFO", "bounded row " + str(index) + " " + "x" * 80) + + contents = path.read_text(encoding="utf-8") + self.assertNotIn("previous secret", contents) + self.assertNotIn("hidden", contents) + self.assertLessEqual(path.stat().st_size, settings.maximum_bytes) + self.assertFalse(old.exists()) + self.assertIn("\033[", console.getvalue()) + self.assertIn("[EXCEPTION]", console.getvalue()) + with self.assertRaisesRegex(ValueError, "unsupported log level"): + logger.event("TRACE", "unsupported") + with self.assertRaisesRegex(ValueError, "raw log end"): + logger.raw("bad", end="\r\n") + + shared = get_application_logger(path, LoggingSettings(console_enabled=False)) + self.assertIs( + shared, + get_application_logger(path, LoggingSettings(console_enabled=False)), + ) + + def test_output_layout_and_logging_configuration_are_complete_and_validated(self) -> None: + """Every global output directory and logging policy value has one typed source.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + layout = ensure_output_layout(root / "output" / "data") + for directory in ( + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, + ): + self.assertTrue(directory.is_dir()) + config_path = root / "logicytics.json" + config_path.write_text( + json.dumps({ + "schema_version": 4, + "logging": { + "level": "debug", + "console_enabled": False, + "color_enabled": False, + "file_enabled": True, + "maximum_bytes": 2048, + "delete_previous": True, + "retention_days": 7, + }, + }), + encoding="utf-8", + ) + configuration = load_config(root, config_path) + self.assertEqual("DEBUG", configuration.logging.level) + self.assertEqual(2048, configuration.logging.maximum_bytes) + self.assertEqual(7, configuration.logging.retention_days) + + for invalid_logging in ( + {"level": "TRACE"}, + {"maximum_bytes": True}, + {"retention_days": -1}, + {"console_enabled": 1}, + {"unknown": True}, + ): + config_path.write_text( + json.dumps({"schema_version": 4, "logging": invalid_logging}), + encoding="utf-8", + ) + with self.subTest(logging=invalid_logging), self.assertRaises(PlanError): + load_config(root, config_path) + def test_maintenance_configuration_requires_pinned_https_and_python_order( self, ) -> None: From 0cc7b308b057a4a877f91ebe1bbfcd563d6c5ae1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 00:23:19 +0300 Subject: [PATCH 179/316] feat: add a typed execution mode registry Centralize the eight user-facing collection modes, their profile membership, scheduling strategy, extension behavior, and performance semantics in an immutable typed registry. Preserve every legacy mode flag as a compatibility alias, expose a machine-readable --modes matrix, document the canonical mappings, and verify parser conflicts and registry completeness with the full test suite. --- README.md | 21 ++++++ TODO.md | 10 +-- logicytics/cli.py | 83 +++++++++++++++------ logicytics/interaction.py | 2 + logicytics/modes.py | 120 +++++++++++++++++++++++++++++++ tests/test_core_functionality.py | 63 ++++++++++++++++ 6 files changed, 272 insertions(+), 27 deletions(-) create mode 100644 logicytics/modes.py diff --git a/README.md b/README.md index bb1c345c..9de65e19 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,27 @@ python -m logicytics run --profile standard --acknowledge-authorization ` Plugin collectors never join any profile unless explicitly included or enabled with `--plugins`. Unknown profile names are rejected before collection begins. +## Execution modes + +`python -m logicytics --modes` prints the authoritative machine-readable mode +matrix. New integrations should use `run --mode `; the historical flags +remain exact compatibility aliases. + +| Mode | Profile | Scheduling | Legacy alias | Additional behavior | +| --- | --- | --- | --- | --- | +| `standard` | standard | sequential | `--default` | deterministic standard run | +| `balanced` | standard | bounded parallel | `--threaded` | configured worker pool | +| `quick` | minimal | configured | `--minimal` | essential inventory | +| `thorough` | deep | configured | `--depth` | extended slower inventory | +| `offline` | offline | configured | none | forbids network access | +| `extensions` | standard | configured | `--modded` | enables declared MODS | +| `non-python` | standard | configured | `--nopy` | only non-Python MODS payloads | +| `performance` | standard | sequential | `--performance-check` | duration report | + +Explicit `--profile`, `--include`, and `--exclude` remain available for advanced +planning. A named mode cannot be combined with a different explicit profile or +with its contradictory worker strategy. + ## Public Python API Importing `logicytics` does not start collection, create output directories, or diff --git a/TODO.md b/TODO.md index 3689e945..969afcfd 100644 --- a/TODO.md +++ b/TODO.md @@ -350,7 +350,7 @@ Windows platform allows. contains the selected profile, explicit collector selections, concurrency, output policy, and post-run action; keep the mutable cancellation handle in the per-run `CollectorContext` rather than the immutable request. -- [ ] Replace hard-coded filename lists and mode-specific branches with a typed +- [x] Replace hard-coded filename lists and mode-specific branches with a typed collector registry. Each collector declares its ID, category, supported platforms, required privileges, estimated cost, dependencies, output types, default profiles, and whether it is safe to parallelize. @@ -364,7 +364,7 @@ Windows platform allows. - [x] Replace one giant execution-list algorithm with a planner that resolves collector dependencies, removes duplicates, validates conflicts, and emits a reproducible execution plan before any collection begins. -- [ ] Replace mode names that imply implementation details with user-facing +- [x] Replace mode names that imply implementation details with user-facing collection profiles. Preserve compatibility aliases for existing flags, but make profiles the canonical internal concept. - [x] Replace a single shared mutable logger with run-scoped structured logging @@ -525,8 +525,8 @@ Windows platform allows. ### Migration rules from the old design -- [ ] Keep existing command-line flags as compatibility aliases during migration. -- [ ] Map every legacy flag to a named v4.0 profile or explicit collector set. +- [x] Keep existing command-line flags as compatibility aliases during migration. +- [x] Map every legacy flag to a named v4.0 profile or explicit collector set. - [ ] Wrap legacy scripts in compatibility collectors before rewriting their data gathering logic. - [ ] Move one collector at a time from direct filesystem writes to artifact APIs. @@ -890,7 +890,7 @@ appropriate default/deep/non-Python mode. ### Phase 2 — Engine and packaging -- [ ] Implement CLI parsing, mode selection, execution scheduling, worker limits, +- [x] Implement CLI parsing, mode selection, execution scheduling, worker limits, performance reporting, and post-run actions. - [x] Implement ZIP creation, SHA-256 hashing, package naming, metadata, and output relocation. diff --git a/logicytics/cli.py b/logicytics/cli.py index d50596e9..49fe9ac5 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -29,6 +29,13 @@ maintenance_diagnostics, write_local_manifest, ) +from logicytics.modes import ( + EXECUTION_MODES, + LEGACY_MODE_ALIASES, + ExecutionStrategy, + mode_matrix, + resolve_execution_mode, +) from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.output_layout import ensure_output_layout from logicytics.runtime import RunSupervisor @@ -40,22 +47,42 @@ def _project_root() -> Path: def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: - profile = "minimal" if getattr(arguments, "minimal", False) else "deep" if getattr(arguments, "depth", - False) else "standard" if getattr( - arguments, "default_mode", False) or getattr(arguments, "threaded", False) else arguments.profile - sequential = getattr(arguments, "sequential", False) - parallel = getattr(arguments, "parallel", False) - performance_check = getattr(arguments, "performance_check", False) - default_mode = getattr(arguments, "default_mode", False) - modded = getattr(arguments, "modded", False) - nopy = getattr(arguments, "nopy", False) + legacy_flags = { + flag: getattr(arguments, flag, False) + for flag in LEGACY_MODE_ALIASES + } + mode = resolve_execution_mode(getattr(arguments, "mode", None), legacy_flags) + explicit_profile = getattr(arguments, "profile", None) + if mode is not None and explicit_profile is not None: + raise ValueError("--profile cannot be combined with a named collection mode") + profile = mode.profile if mode is not None else explicit_profile or "standard" + explicit_sequential = getattr(arguments, "sequential", False) + explicit_parallel = getattr(arguments, "parallel", False) + if mode is not None: + if explicit_sequential and mode.strategy is ExecutionStrategy.PARALLEL: + if legacy_flags["threaded"]: + raise ValueError("sequential execution conflicts with legacy --threaded mode") + raise ValueError(f"{mode.name} mode requires configured parallel execution") + if explicit_parallel and mode.strategy is ExecutionStrategy.SEQUENTIAL: + if legacy_flags["performance_check"] or legacy_flags["default_mode"]: + raise ValueError( + "parallel execution conflicts with sequential performance/default mode" + ) + raise ValueError(f"{mode.name} mode requires sequential execution") + sequential = explicit_sequential or ( + mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL + ) + parallel = explicit_parallel or ( + mode is not None and mode.strategy is ExecutionStrategy.PARALLEL + ) + performance_check = mode.performance_check if mode is not None else False + enable_mods = getattr(arguments, "mods", False) or ( + mode.enable_mods if mode is not None else False + ) + non_python_only = mode.non_python_only if mode is not None else False if sequential and arguments.workers is not None and arguments.workers != 1: raise ValueError("sequential execution requires --workers=1") - if parallel and (performance_check or default_mode): - raise ValueError("parallel execution conflicts with sequential performance/default mode") - if sequential and getattr(arguments, "threaded", False): - raise ValueError("sequential execution conflicts with legacy --threaded mode") - worker_count = 1 if sequential or performance_check or default_mode else arguments.workers or default_workers + worker_count = 1 if sequential else arguments.workers or default_workers if parallel and worker_count < 2: raise ValueError("parallel execution requires at least two configured workers") parent_run_id: str | None = None @@ -93,8 +120,8 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: include=tuple(arguments.include), exclude=tuple(arguments.exclude), enable_plugins=arguments.plugins, - enable_mods=getattr(arguments, "mods", False) or modded or nopy, - non_python_only=nopy, + enable_mods=enable_mods, + non_python_only=non_python_only, max_workers=worker_count, acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), @@ -119,13 +146,14 @@ def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") parser.add_argument("--usage", action="store_true", help="Show local interaction statistics and create a usage graph.") + parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") subcommands = parser.add_subparsers(dest="command") for command in ("preflight", "debug", "update", "dev", "plan", "run"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") subparser.add_argument( "--profile", - default="standard", + default=None, choices=tuple(BUILTIN_PROFILES), help="Named built-in collector membership and access policy.", ) @@ -174,6 +202,11 @@ def _parser() -> argparse.ArgumentParser: help="Explicitly run isolated collectors with the configured bounded worker limit.", ) mode = subparser.add_mutually_exclusive_group() + mode.add_argument( + "--mode", + choices=tuple(EXECUTION_MODES), + help="Select one user-facing typed execution mode.", + ) mode.add_argument("--default", dest="default_mode", action="store_true", help="Run the standard built-in profile.") mode.add_argument("--threaded", action="store_true", @@ -319,14 +352,17 @@ def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" parser = _parser() arguments = parser.parse_args(argv) - if arguments.usage and arguments.match: - parser.error("--usage cannot be combined with --match") - if arguments.command is not None and (arguments.usage or arguments.match): - parser.error("--usage and --match are standalone actions") + standalone_actions = sum(bool(value) for value in (arguments.usage, arguments.match, arguments.modes)) + if standalone_actions > 1: + parser.error("--usage, --match, and --modes are mutually exclusive") + if arguments.command is not None and standalone_actions: + parser.error("--usage, --match, and --modes are standalone actions") if arguments.usage: arguments.command = "usage" elif arguments.match: arguments.command = "match" + elif arguments.modes: + arguments.command = "modes" if arguments.command is None: parser.print_help() return 0 @@ -367,6 +403,9 @@ def main(argv: list[str] | None = None) -> int: graph_path = write_usage_graph(configuration.runtime.output_root / "flag_usage.svg", statistics) print(json.dumps({**statistics, "graph_path": str(graph_path)}, indent=2, sort_keys=True)) return 0 + if arguments.command == "modes": + print(json.dumps(mode_matrix(), indent=2, sort_keys=True)) + return 0 report = preflight(root, configuration_hash=configuration.fingerprint()) if arguments.command == "preflight": validation = report.to_dict( @@ -434,7 +473,7 @@ def main(argv: list[str] | None = None) -> int: f"platform_error={record.failure['platform_error']} " f"remediation={record.failure['remediation']}" ) - if arguments.performance_check: + if plan.request.performance_check: performance_path = outcome.run_directory / "logs" / "performance.json" print(f"Performance: {performance_path}") if outcome.manifest.package and "path" in outcome.manifest.package: diff --git a/logicytics/interaction.py b/logicytics/interaction.py index 3574c752..bd7955d2 100644 --- a/logicytics/interaction.py +++ b/logicytics/interaction.py @@ -25,6 +25,8 @@ "debug": "diagnostic environment configuration and integrity checks", "update": "check or explicitly update the Git checkout", "dev": "developer contribution manifest and version checks", + "mode": "select a user facing typed execution mode", + "modes": "show execution mode profiles scheduling and compatibility aliases", } _TOKEN = re.compile(r"[a-z0-9]+") diff --git a/logicytics/modes.py b/logicytics/modes.py new file mode 100644 index 00000000..65c763f5 --- /dev/null +++ b/logicytics/modes.py @@ -0,0 +1,120 @@ +"""Typed user-facing execution modes and legacy CLI compatibility aliases.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import Enum +from types import MappingProxyType +from typing import Mapping + + +class ExecutionStrategy(str, Enum): + """Worker scheduling policy owned by an execution mode.""" + + CONFIGURED = "configured" + SEQUENTIAL = "sequential" + PARALLEL = "parallel" + + +@dataclass(frozen=True, slots=True) +class ExecutionMode: + """Complete profile, extension, and scheduling behavior for one named mode.""" + + name: str + description: str + profile: str + strategy: ExecutionStrategy = ExecutionStrategy.CONFIGURED + enable_mods: bool = False + non_python_only: bool = False + performance_check: bool = False + + +_MODE_LIST = ( + ExecutionMode( + "standard", + "Standard built-in collection with deterministic sequential execution.", + "standard", + ExecutionStrategy.SEQUENTIAL, + ), + ExecutionMode( + "balanced", + "Standard built-in collection using the configured bounded worker pool.", + "standard", + ExecutionStrategy.PARALLEL, + ), + ExecutionMode("quick", "Fast essential local inventory.", "minimal"), + ExecutionMode("thorough", "Extended and potentially slower local inventory.", "deep"), + ExecutionMode("offline", "Local-only collection with network access forbidden.", "offline"), + ExecutionMode( + "extensions", + "Standard collection followed by explicitly declared MODS extensions.", + "standard", + enable_mods=True, + ), + ExecutionMode( + "non-python", + "Run only declared PowerShell, batch, and executable MODS payloads.", + "standard", + enable_mods=True, + non_python_only=True, + ), + ExecutionMode( + "performance", + "Sequential standard collection with per-collector duration reporting.", + "standard", + ExecutionStrategy.SEQUENTIAL, + performance_check=True, + ), +) + +EXECUTION_MODES: Mapping[str, ExecutionMode] = MappingProxyType( + {mode.name: mode for mode in _MODE_LIST} +) +LEGACY_MODE_ALIASES: Mapping[str, str] = MappingProxyType({ + "default_mode": "standard", + "threaded": "balanced", + "minimal": "quick", + "depth": "thorough", + "modded": "extensions", + "nopy": "non-python", + "performance_check": "performance", +}) + + +def resolve_execution_mode( + selected: str | None, + legacy_flags: Mapping[str, bool], +) -> ExecutionMode | None: + """Resolve a user-facing name or exactly one parser-exclusive legacy alias.""" + aliases = [name for flag, name in LEGACY_MODE_ALIASES.items() if legacy_flags.get(flag, False)] + if selected is not None and aliases: + raise ValueError("--mode cannot be combined with a legacy mode alias") + if len(aliases) > 1: + raise ValueError("legacy collection mode aliases are mutually exclusive") + name = selected or (aliases[0] if aliases else None) + if name is None: + return None + try: + return EXECUTION_MODES[name] + except KeyError as error: + raise ValueError(f"unknown execution mode: {name}") from error + + +def mode_matrix() -> list[dict[str, object]]: + """Return a stable JSON-ready mode inclusion and scheduling matrix.""" + aliases_by_mode = { + name: sorted( + f"--{flag.removesuffix('_mode').replace('_', '-')}" + for flag, target in LEGACY_MODE_ALIASES.items() + if target == name + ) + for name in EXECUTION_MODES + } + return [ + { + **asdict(mode), + "strategy": mode.strategy.value, + "legacy_aliases": aliases_by_mode[mode.name], + } + for mode in EXECUTION_MODES.values() + ] diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index b24e2a39..6b0d8561 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -79,6 +79,7 @@ from logicytics.errors import ArtifactError, PlanError, PreflightError from logicytics.packaging import package_run from logicytics.output_layout import ensure_output_layout +from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor @@ -1819,6 +1820,68 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) + def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: + """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" + parser = _parser() + expected = { + "standard": ("standard", 1, False, False, False), + "balanced": ("standard", 4, False, False, False), + "quick": ("minimal", 4, False, False, False), + "thorough": ("deep", 4, False, False, False), + "offline": ("offline", 4, False, False, False), + "extensions": ("standard", 4, True, False, False), + "non-python": ("standard", 4, True, True, False), + "performance": ("standard", 1, False, False, True), + } + self.assertEqual(set(expected), set(EXECUTION_MODES)) + self.assertEqual(set(expected), {item["name"] for item in mode_matrix()}) + for name, contract in expected.items(): + with self.subTest(mode=name): + request = _request(parser.parse_args(["run", "--mode", name]), 4) + self.assertEqual( + contract, + ( + request.profile, + request.max_workers, + request.enable_mods, + request.non_python_only, + request.performance_check, + ), + ) + + alias_flags = { + "default_mode": "--default", + "threaded": "--threaded", + "minimal": "--minimal", + "depth": "--depth", + "modded": "--modded", + "nopy": "--nopy", + "performance_check": "--performance-check", + } + for field, mode_name in LEGACY_MODE_ALIASES.items(): + with self.subTest(alias=alias_flags[field]): + legacy = _request(parser.parse_args(["run", alias_flags[field]]), 4) + named = _request(parser.parse_args(["run", "--mode", mode_name]), 4) + self.assertEqual(named, legacy) + + with self.assertRaisesRegex(ValueError, "--profile"): + _request(parser.parse_args(["run", "--mode", "quick", "--profile", "deep"]), 4) + with patch("sys.stderr"), self.assertRaises(SystemExit): + parser.parse_args(["run", "--mode", "quick", "--minimal"]) + + def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: + """Users and release checks can inspect the same authoritative mode registry.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", output + ): + self.assertEqual(0, main(["--modes"])) + payload = json.loads(output.getvalue()) + self.assertEqual(list(EXECUTION_MODES), [item["name"] for item in payload]) + self.assertTrue(all("legacy_aliases" in item for item in payload)) + def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: """An empty invocation is useful while contradictory legacy actions fail immediately.""" output = io.StringIO() From 2867315be0fabe5177db6168fef044709cf494d5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 00:27:28 +0300 Subject: [PATCH 180/316] docs: formalize configuration boundaries Document the immutable product-policy sections, typed profile ownership, isolated per-collector settings, and request-scoped runtime overrides as separate v4 contracts. Specify every bounded memory-map output setting, add a regression test proving configuration and invocation isolation, and reconcile the completed configuration requirements in the project checklist. --- README.md | 19 +++++++++++++++--- TODO.md | 4 ++-- tests/test_core_functionality.py | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 9de65e19..456b85f0 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,13 @@ before asking Windows to open the file through its associated application. ## Configuration -An optional project-root `logicytics.json` uses schema version `4` and separates -engine-wide `runtime` options from per-collector `collectors` settings: +An optional project-root `logicytics.json` uses schema version `4`. Product +policy is split into immutable `runtime`, `interaction`, `maintenance`, and +`logging` sections. Profile membership remains in the typed collector registry, +per-collector options remain under `collectors`, and invocation-only overrides +such as explicit selections, worker strategy, authorization, and capabilities +remain in `RunRequest`; none of those runtime overrides mutate the loaded +configuration. ```json { @@ -238,13 +243,21 @@ engine-wide `runtime` options from per-collector `collectors` settings: }, "collectors": { "core.filesystem.system_drive_tree": {"max_entries": 5000, "max_depth": 12}, - "core.process.memory_map": {"max_regions": 5000, "dump_directory": "memory_maps"} + "core.process.memory_map": { + "max_regions": 5000, + "output_limit_bytes": 67108864, + "disk_safety_margin_bytes": 104857600, + "dump_directory": "memory_maps" + } } } ``` Shipped filesystem, network, packet, sensitive-inventory, and metadata-only memory-map collector options are strictly typed and bounded before planning. +The memory-map output limit, free-space safety margin, and dump directory are +collector-scoped; the directory must be relative to that collector's isolated +workspace. Unknown engine fields, duplicate JSON keys, unsafe workspace paths, and invalid collector IDs fail closed; plugin-owned setting fields remain extensible. diff --git a/TODO.md b/TODO.md index 969afcfd..8d8c275e 100644 --- a/TODO.md +++ b/TODO.md @@ -460,7 +460,7 @@ Windows platform allows. - [x] Replace scattered string lookups with a validated typed configuration object. - [x] Fail at plan-validation time for invalid values, rather than during a collector's execution. -- [ ] Separate product settings, profile settings, collector settings, and runtime +- [x] Separate product settings, profile settings, collector settings, and runtime overrides. - [x] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` with documented collector membership. @@ -866,7 +866,7 @@ appropriate default/deep/non-Python mode. and preference-history persistence. - [x] Recreate flag-matching settings for model name, minimum accuracy, and model debug output. -- [ ] Recreate memory-dump settings for file-size limit, safety margin, and dump +- [x] Recreate memory-dump settings for file-size limit, safety margin, and dump directory. - [x] Recreate network sampling settings for count and interval. - [x] Recreate packet-sniffer settings for interface, packet count, timeout, and diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 6b0d8561..06a65090 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1742,6 +1742,40 @@ def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_p ) self.assertEqual(settings, load_config(root).settings_for(collector_id)) + def test_configuration_profile_collector_and_request_boundaries_are_isolated(self) -> None: + """Product, profile, collector, and invocation settings remain separate contracts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_id = "core.process.memory_map" + collector_settings = { + "output_limit_bytes": 8192, + "disk_safety_margin_bytes": 4096, + "dump_directory": "maps", + } + (root / "logicytics.json").write_text( + json.dumps({ + "schema_version": 4, + "runtime": {"default_max_workers": 3, "maximum_workers": 8}, + "collectors": {collector_id: collector_settings}, + }), + encoding="utf-8", + ) + configuration = load_config(root) + request = RunRequest( + profile="deep", + include=(collector_id,), + max_workers=1, + acknowledge_authorization=True, + ) + + self.assertEqual(3, configuration.runtime.default_max_workers) + self.assertEqual(collector_settings, configuration.settings_for(collector_id)) + self.assertEqual("deep", request.profile) + self.assertEqual((collector_id,), request.include) + self.assertEqual(1, request.max_workers) + self.assertNotIn("profile", configuration.to_manifest_dict()["runtime"]) + self.assertNotIn("max_workers", configuration.to_manifest_dict()["runtime"]) + def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> None: """Run requests must be immutable, typed declarations before a plan exists.""" collector_id = "core.system.system_info" From be945591b3cd12cd1dbcdfaaab40db26c1903bb8 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 00:31:37 +0300 Subject: [PATCH 181/316] feat: formalize parallel event log collection Declare all three Windows event-log collectors as independent general-resource workers so bounded-parallel plans may query Application, Security, and System channels concurrently while preserving separate CSV artifacts. Document the packet graph's resource-free DOT lifecycle, add shipped-contract regression coverage, and reconcile the completed core and deep collector delivery phases. --- TODO.md | 14 +++++++------- core/event_log/application_events.py | 12 +++++++++++- core/event_log/security_events.py | 11 ++++++++++- core/event_log/system_events.py | 3 +++ core/packet/connection_graph.py | 2 +- tests/test_shipped_collectors.py | 23 +++++++++++++++++++++++ 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index 8d8c275e..86d58514 100644 --- a/TODO.md +++ b/TODO.md @@ -739,7 +739,7 @@ appropriate default/deep/non-Python mode. metadata for IP/TCP/UDP/ICMP traffic. - [x] Export packet observations to CSV. - [x] Build and save a source/destination network graph with protocol edge labels. -- [ ] Clean up packet-sniffer graph state and plotting resources. +- [x] Clean up packet-sniffer graph state and plotting resources. ### Wireless, Bluetooth, and removable-device history @@ -763,7 +763,7 @@ appropriate default/deep/non-Python mode. - [x] Read Security, Application, and System event logs through the modern Windows event API. - [x] Record event category, generated time, source, event ID, type, and message data in separate bounded CSV reports. -- [ ] Run the event-log parsers concurrently and write separate reports. +- [x] Run the event-log parsers concurrently and write separate reports. - [x] Export the HKLM registry hive to a `.reg` backup. - [x] Enumerate logical volumes with drive type, filesystem, free space, size, and volume name. @@ -898,16 +898,16 @@ appropriate default/deep/non-Python mode. ### Phase 3 — Core collectors -- [ ] Implement system/hardware inventory and command-output collectors. -- [ ] Implement process, memory, filesystem, network, and adapter collectors. -- [ ] Implement registry, event-log, storage, encryption, Bluetooth, USB, Wi-Fi, +- [x] Implement system/hardware inventory and command-output collectors. +- [x] Implement process, memory, filesystem, network, and adapter collectors. +- [x] Implement registry, event-log, storage, encryption, Bluetooth, USB, Wi-Fi, SSH, browser, and media collectors. ### Phase 4 — Deep collection and visualization -- [ ] Implement sensitive-file mining, recursive directory/tree collection, and +- [x] Implement sensitive-file mining, recursive directory/tree collection, and Sysinternals integration. -- [ ] Implement packet capture, CSV export, and network graph generation. +- [x] Implement packet capture, CSV export, and network graph generation. - [x] Implement flag-history statistics and usage graph generation. ### Phase 5 — Maintenance and release readiness diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 266101e0..ebe27ba2 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -5,7 +5,15 @@ import subprocess from shutil import which -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + ResourceClass, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus _MAX_EVENTS = 1_000 @@ -34,6 +42,8 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), + parallel_safe=True, + resource_class=ResourceClass.GENERAL, default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=8 * 1024 * 1024, diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index 268c4ff4..6668fbcc 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -5,7 +5,15 @@ import subprocess from shutil import which -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + ResourceClass, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus _MAX_EVENTS = 1_000 @@ -34,6 +42,7 @@ def metadata(cls) -> CollectorMetadata: description="Exports up to 1,000 local Windows Security events as CSV.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), + parallel_safe=True, resource_class=ResourceClass.GENERAL, default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=8 * 1024 * 1024, ) diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py index d35e274b..25b428f8 100644 --- a/core/event_log/system_events.py +++ b/core/event_log/system_events.py @@ -10,6 +10,7 @@ CollectorMetadata, CollectorResult, CoreCollector, + ResourceClass, Specialty, ValidationResult, ) @@ -41,6 +42,8 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), + parallel_safe=True, + resource_class=ResourceClass.GENERAL, default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=8 * 1024 * 1024, diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 8aa1db8d..ba3aec4a 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -76,4 +76,4 @@ def quote(value: str) -> str: return CollectorResult.succeeded("connection graph collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: - """Release no resources because netstat exits before the result is returned.""" + """Retain no graph or plot state; DOT generation uses only collection-local values.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index c2e15de7..068b725d 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -5,6 +5,7 @@ import unittest from pathlib import Path +from logicytics import ResourceClass from logicytics.discovery import preflight @@ -86,6 +87,28 @@ def test_shipped_collectors_pass_preflight(self) -> None: {candidate.metadata.id for candidate in report.valid if candidate.metadata}, ) + def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling(self) -> None: + """The independent event channels may overlap while retaining separate identities.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + event_collectors = { + candidate.metadata.id: candidate.metadata + for candidate in report.valid + if candidate.metadata and candidate.metadata.id.startswith("core.event_log.") + } + self.assertEqual( + { + "core.event_log.application_events", + "core.event_log.security_events", + "core.event_log.system_events", + }, + set(event_collectors), + ) + for metadata in event_collectors.values(): + self.assertTrue(metadata.parallel_safe) + self.assertIs(ResourceClass.GENERAL, metadata.resource_class) + self.assertEqual(("text/csv",), metadata.output_media_types) + if __name__ == "__main__": unittest.main() From d29418c51e7f1d0ff9cdfe1ef897a4bb2057e0ad Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 00:35:23 +0300 Subject: [PATCH 182/316] refactor: centralize collector concurrency Remove the sensitive-file and system-drive collectors' private thread pools so the bounded run supervisor remains the sole owner of collection concurrency and cancellation. Make directory traversal deterministic, remove the obsolete listing worker setting, retain partial-copy cleanup, and reconcile the completed temporary-workspace and migration requirements. --- TODO.md | 10 +-- core/filesystem/sensitive_file_inventory.py | 17 +++-- core/filesystem/system_drive_listing.py | 71 ++++++++------------- logicytics/configuration.py | 1 - tests/test_core_functionality.py | 4 +- 5 files changed, 45 insertions(+), 58 deletions(-) diff --git a/TODO.md b/TODO.md index 86d58514..d0afaa52 100644 --- a/TODO.md +++ b/TODO.md @@ -97,7 +97,7 @@ collector is launched. - [x] Add `core.filesystem.system_drive_tree`, a deep-profile, filesystem-read, bounded recursive system-drive tree with configurable depth and entry limits. - [x] Add `core.filesystem.system_drive_listing`, a deep-profile, filesystem-read, - bounded threaded recursive system-drive listing. + bounded recursive system-drive listing scheduled by the run supervisor. - [x] Add `core.network.bandwidth_sample`, a deep-profile, subprocess-gated, configurable local adapter bandwidth sampler. - [x] Add `core.bluetooth.bluetooth_addresses`, a deep-profile, subprocess-gated, @@ -533,7 +533,7 @@ Windows platform allows. - [ ] Keep legacy output names as aliases or compatibility copies only where users rely on them; make the run artifact tree canonical. - [ ] Remove global mutable state after all collectors use `RunContext`. -- [ ] Remove direct `exit()` calls, current-directory assumptions, nested worker +- [x] Remove direct `exit()` calls, current-directory assumptions, nested worker pools, and package-wide cleanup from collectors. - [ ] Remove compatibility shims only after the v4.0 API and migration documentation cover their replacement. @@ -792,8 +792,8 @@ appropriate default/deep/non-Python mode. - [x] Restrict sensitive-file mining to the supported document, data, archive, database, configuration, log, and text extensions. - [x] Copy matching files into a dedicated report directory. -- [x] Use concurrent search/copy operations and skip files over the configured - per-file size ceiling. +- [x] Use scheduler-owned collector concurrency, cancellation-aware copy operations, + and skip files over the configured per-file size ceiling. - [x] Back up the current user's Pictures and Videos directories. - [x] Recursively collect supported image/video formats, including JPG, JPEG, PNG, and MP4. @@ -885,7 +885,7 @@ appropriate default/deep/non-Python mode. - [x] Define the v4.0 package layout and run manifest format. - [x] Implement configuration loading, structured logging, file discovery, command execution, privilege checks, cancellation, and error aggregation. -- [ ] Implement the temporary run directory and cleanup lifecycle. +- [x] Implement the temporary run directory and cleanup lifecycle. - [x] Rename ACCESS folder to output/ and LOGS/ to logs/, PACKAGES/ to data/ (and remove RUNS/ as it should be the same as data/) ### Phase 2 — Engine and packaging diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index d0146a4c..6b0c5a31 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -4,7 +4,6 @@ import os import shutil -from concurrent.futures import ThreadPoolExecutor from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult @@ -57,7 +56,7 @@ def validate(self, context: CollectorContext) -> ValidationResult: return ValidationResult(True) def collect(self, context: CollectorContext) -> CollectorResult: - """Find matches and copy them concurrently into a dedicated evidence directory.""" + """Find matches and copy them through the scheduler-owned collector worker.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before sensitive-file inventory") root = Path(str(context.settings.get("root", os.environ.get("SystemDrive", "C:") + "\\"))) @@ -87,11 +86,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: if len(matches) >= max_matches: break destination_root = context.workspace / "sensitive_file_inventory" - with ThreadPoolExecutor(max_workers=4) as executor: - copied = [item for item in executor.map(lambda source: _copy(source, - destination_root / source.drive.replace(":", - "") / source.relative_to( - root)), matches) if item is not None] + copied: list[Path] = [] + for source in matches: + if context.is_cancelled: + for copied_path in copied: + copied_path.unlink(missing_ok=True) + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file copy") + destination = destination_root / source.drive.replace(":", "") / source.relative_to(root) + if copied_path := _copy(source, destination): + copied.append(copied_path) if context.is_cancelled: for copied_path in copied: copied_path.unlink(missing_ok=True) diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py index a0114605..9bf90d91 100644 --- a/core/filesystem/system_drive_listing.py +++ b/core/filesystem/system_drive_listing.py @@ -1,9 +1,9 @@ -"""Create a bounded threaded recursive listing of the Windows system drive.""" +"""Create a bounded recursive listing of the Windows system drive.""" from __future__ import annotations import os -from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait +from collections import deque from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult @@ -11,10 +11,8 @@ _DEFAULT_MAX_ENTRIES = 10_000 _DEFAULT_MAX_DEPTH = 16 -_DEFAULT_WORKERS = 4 _HARD_MAX_ENTRIES = 50_000 _HARD_MAX_DEPTH = 32 -_HARD_MAX_WORKERS = 8 def _setting(settings: object, key: str, default: int, maximum: int) -> int: @@ -43,16 +41,16 @@ def _scan_directory(directory: Path) -> tuple[list[str], list[Path]]: class SystemDriveListingCollector(CoreCollector): - """Capture a bounded threaded recursive directory listing without reading file contents.""" + """Capture a bounded recursive directory listing without reading file contents.""" @classmethod def metadata(cls) -> CollectorMetadata: - """Declare the filesystem-read, threaded system-drive listing contract.""" + """Declare the filesystem-read system-drive listing contract.""" return CollectorMetadata( id="core.filesystem.system_drive_listing", name="System drive listing", version="4.0.0", specialty=Specialty.FILESYSTEM, output_media_types=("text/plain",), - description="Exports a configurable, bounded threaded recursive listing of the Windows system drive.", + description="Exports a configurable, bounded recursive listing of the Windows system drive.", author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=180, @@ -69,50 +67,37 @@ def validate(self, context: CollectorContext) -> ValidationResult: return ValidationResult(True) def collect(self, context: CollectorContext) -> CollectorResult: - """Traverse the system drive in bounded parallel batches and register text evidence.""" + """Traverse deterministically inside the scheduler-owned worker and register text evidence.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive listing collection") maximum_entries = _setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) maximum_depth = _setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) - workers = _setting(context.settings, "workers", _DEFAULT_WORKERS, _HARD_MAX_WORKERS) root = Path(os.environ.get("SystemDrive", "C:") + "\\") lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}", - f"# workers={workers}"] - pending: dict[Future[tuple[list[str], list[Path]]], tuple[Path, int]] = {} + "# scheduling=run_supervisor"] + pending = deque([(root, 0)]) entries = 0 truncated = False context.report_progress("system_drive_listing_started", max_entries=maximum_entries, max_depth=maximum_depth, - workers=workers) - with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="LogicyticsList") as executor: - pending[executor.submit(_scan_directory, root)] = (root, 0) - while pending and entries < maximum_entries: - done, _ = wait(pending, return_when=FIRST_COMPLETED) - for future in done: - directory, depth = pending.pop(future) - names, children = future.result() - for name in names: - if entries >= maximum_entries: - truncated = True - break - lines.append(str(Path(name).relative_to(root))) - entries += 1 - if truncated: - break - if depth < maximum_depth: - for child in children: - if len(pending) >= maximum_entries: - truncated = True - break - pending[executor.submit(_scan_directory, child)] = (child, depth + 1) - if truncated: - break - if context.is_cancelled: - for future in pending: - future.cancel() - return CollectorResult(CollectorStatus.CANCELLED, - "cancelled during system-drive listing collection") - if truncated: + scheduling="run_supervisor") + while pending and entries < maximum_entries: + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, + "cancelled during system-drive listing collection") + directory, depth = pending.popleft() + names, children = _scan_directory(directory) + for name in names: + if entries >= maximum_entries: + truncated = True break + lines.append(str(Path(name).relative_to(root))) + entries += 1 + if truncated: + break + if depth < maximum_depth: + pending.extend((child, depth + 1) for child in children) + if pending and entries >= maximum_entries: + truncated = True if truncated: lines.append("# truncated=true") output = context.workspace / "system_drive_listing.txt" @@ -120,8 +105,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: artifact = context.artifacts.register_file(output, media_type="text/plain") context.report_progress("system_drive_listing_finished", entry_count=entries, truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) - summary = "threaded system-drive listing collected" if not truncated else "threaded system-drive listing collected with configured entry limit" + summary = "system-drive listing collected" if not truncated else "system-drive listing collected with configured entry limit" return CollectorResult.succeeded(summary, (artifact,)) def cleanup(self, context: CollectorContext) -> None: - """Release no resources because the scoped thread pool has already shut down.""" + """Release no resources because traversal retains only collection-local state.""" diff --git a/logicytics/configuration.py b/logicytics/configuration.py index a3043062..6be6b3dc 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -75,7 +75,6 @@ class CollectorSettingRule: "core.filesystem.system_drive_listing": { "max_entries": CollectorSettingRule("integer", 1, 50_000), "max_depth": CollectorSettingRule("integer", 1, 32), - "workers": CollectorSettingRule("integer", 1, 8), }, "core.filesystem.sensitive_file_inventory": { "root": CollectorSettingRule("absolute_path"), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 06a65090..1e7a2b2c 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1677,7 +1677,7 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) invalid = ( ("core.filesystem.system_drive_tree", {"max_entries": True}, "max_entries"), ("core.filesystem.system_drive_tree", {"max_depth": 33}, "max_depth"), - ("core.filesystem.system_drive_listing", {"workers": 9}, "workers"), + ("core.filesystem.system_drive_listing", {"workers": 2}, "unsupported settings"), ("core.filesystem.system_drive_listing", {"max_entries": "100"}, "max_entries"), ("core.filesystem.sensitive_file_inventory", {"max_directories": 50_001}, "max_directories"), ("core.filesystem.sensitive_file_inventory", {"max_matches": 0}, "max_matches"), @@ -1694,7 +1694,7 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) self.assertFalse((root / "output").exists()) valid = { "core.filesystem.system_drive_tree": {"max_entries": 100, "max_depth": 3}, - "core.filesystem.system_drive_listing": {"max_entries": 200, "max_depth": 4, "workers": 2}, + "core.filesystem.system_drive_listing": {"max_entries": 200, "max_depth": 4}, "core.filesystem.sensitive_file_inventory": { "root": str(root), "max_directories": 100, "max_matches": 10, }, From ddeb7fc28cf9213cf27fa41dadd03a8fc77b9b74 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:11:33 +0300 Subject: [PATCH 183/316] feat: expose complete mode collector membership Run strict preflight for the --modes action and emit a versioned two-way matrix that lists each mode's exact collector IDs and represents every discovered core, plugin, and MOD collector once. Distinguish mode-selected, explicit-only, and quarantined collectors, document the machine-readable schema, add complete repository coverage assertions, and reconcile the v4 mode-matrix checklist requirements. --- README.md | 10 ++-- TODO.md | 4 +- logicytics/cli.py | 3 +- logicytics/modes.py | 91 ++++++++++++++++++++++++++++++-- tests/test_core_functionality.py | 8 +-- tests/test_shipped_collectors.py | 31 +++++++++++ 6 files changed, 134 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 456b85f0..5713a77e 100644 --- a/README.md +++ b/README.md @@ -157,9 +157,13 @@ with `--plugins`. Unknown profile names are rejected before collection begins. ## Execution modes -`python -m logicytics --modes` prints the authoritative machine-readable mode -matrix. New integrations should use `run --mode `; the historical flags -remain exact compatibility aliases. +`python -m logicytics --modes` runs strict collector preflight and prints the +authoritative, versioned machine-readable mode matrix. Its `modes` rows contain +the exact validated `collector_ids` selected by each mode. Its `collectors` rows +represent every discovered core, plugin, and MOD collector exactly once with its +mode assignments, validation state, execution type, and either `manual_only` or +quarantine status. New integrations should use `run --mode `; the +historical flags remain exact compatibility aliases. | Mode | Profile | Scheduling | Legacy alias | Additional behavior | | --- | --- | --- | --- | --- | diff --git a/TODO.md b/TODO.md index d0afaa52..09a9aae8 100644 --- a/TODO.md +++ b/TODO.md @@ -919,7 +919,7 @@ appropriate default/deep/non-Python mode. - [ ] Test default, threaded, minimal, deep, non-Python, performance, modded, debug, update, usage, shutdown, reboot, cancellation, and permission-denied flows. -- [ ] Verify that every collector is represented in the CLI mode matrix and the +- [x] Verify that every collector is represented in the CLI mode matrix and the v4.0 documentation. - [ ] Verify that packages contain generated evidence and metadata only. - [x] Verify that hashes reproduce from the final package bytes. @@ -930,7 +930,7 @@ appropriate default/deep/non-Python mode. - [ ] Every feature in this file has an implementation owner/status. - [ ] Every collector can be run directly and through the orchestrator. -- [ ] Every mode has an explicit collector inclusion matrix. +- [x] Every mode has an explicit collector inclusion matrix. - [ ] Every output has a documented path, format, and retention rule. - [x] A failed collector cannot silently make the run appear successful. - [x] A cancelled run leaves a recoverable partial-run report and cleans only its diff --git a/logicytics/cli.py b/logicytics/cli.py index 49fe9ac5..fc4f5671 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -404,7 +404,8 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({**statistics, "graph_path": str(graph_path)}, indent=2, sort_keys=True)) return 0 if arguments.command == "modes": - print(json.dumps(mode_matrix(), indent=2, sort_keys=True)) + report = preflight(root, configuration_hash=configuration.fingerprint()) + print(json.dumps(mode_matrix((*report.valid, *report.invalid)), indent=2, sort_keys=True)) return 0 report = preflight(root, configuration_hash=configuration.fingerprint()) if arguments.command == "preflight": diff --git a/logicytics/modes.py b/logicytics/modes.py index 65c763f5..7e9d0d73 100644 --- a/logicytics/modes.py +++ b/logicytics/modes.py @@ -5,7 +5,10 @@ from dataclasses import asdict, dataclass from enum import Enum from types import MappingProxyType -from typing import Mapping +from typing import Iterable, Mapping + +from logicytics.contracts import CollectorKind +from logicytics.discovery import CollectorCandidate class ExecutionStrategy(str, Enum): @@ -100,8 +103,28 @@ def resolve_execution_mode( raise ValueError(f"unknown execution mode: {name}") from error -def mode_matrix() -> list[dict[str, object]]: - """Return a stable JSON-ready mode inclusion and scheduling matrix.""" +def _candidate_modes(candidate: CollectorCandidate) -> tuple[str, ...]: + """Return every named mode that selects one validated collector by default.""" + if candidate.metadata is None: + return () + metadata = candidate.metadata + selected: list[str] = [] + for mode in EXECUTION_MODES.values(): + if candidate.kind is CollectorKind.MOD: + enabled = mode.enable_mods and not ( + mode.non_python_only and candidate.execution_type == "mod_python" + ) + elif candidate.kind is CollectorKind.PLUGIN: + enabled = False + else: + enabled = not mode.non_python_only and mode.profile in metadata.default_profiles + if enabled: + selected.append(mode.name) + return tuple(selected) + + +def mode_matrix(candidates: Iterable[CollectorCandidate] = ()) -> dict[str, object]: + """Return the versioned mode definitions and complete collector inclusion matrix.""" aliases_by_mode = { name: sorted( f"--{flag.removesuffix('_mode').replace('_', '-')}" @@ -110,11 +133,71 @@ def mode_matrix() -> list[dict[str, object]]: ) for name in EXECUTION_MODES } - return [ + ordered_candidates = sorted( + candidates, + key=lambda candidate: ( + candidate.metadata.id if candidate.metadata is not None else candidate.selection_id, + str(candidate.path), + ), + ) + collector_rows = [] + memberships: dict[str, list[str]] = {name: [] for name in EXECUTION_MODES} + for candidate in ordered_candidates: + collector_id = candidate.metadata.id if candidate.metadata is not None else candidate.selection_id + assigned_modes = _candidate_modes(candidate) + for mode_name in assigned_modes: + memberships[mode_name].append(collector_id) + errors = [*candidate.static_errors] + if candidate.runtime_error: + errors.append(candidate.runtime_error) + collector_rows.append({ + "id": collector_id, + "kind": candidate.kind.value, + "valid": candidate.valid, + "modes": list(assigned_modes), + "manual_only": candidate.valid and not assigned_modes, + "execution_type": candidate.execution_type, + "validation_errors": errors, + }) + modes = [ { **asdict(mode), "strategy": mode.strategy.value, "legacy_aliases": aliases_by_mode[mode.name], + "collector_ids": memberships[mode.name], } for mode in EXECUTION_MODES.values() ] + return { + "schema_version": 1, + "modes": modes, + "collectors": collector_rows, + } + + +def render_mode_matrix_markdown(matrix: Mapping[str, object]) -> str: + """Render the collector side of a mode matrix as deterministic v4 documentation.""" + rows = [ + "# Logicytics v4 collector mode matrix", + "", + "This file is generated from strict collector preflight metadata. Use", + "`python -m logicytics --modes` for the complete machine-readable contract.", + "", + "| Collector | Kind | Valid | Included modes | Selection |", + "| --- | --- | --- | --- | --- |", + ] + for item in matrix.get("collectors", []): + collector = dict(item) + modes = ", ".join(f"`{name}`" for name in collector["modes"]) or "none" + if not collector["valid"]: + selection = "quarantined" + elif collector["manual_only"]: + selection = "explicit include only" + else: + selection = "mode selected" + rows.append( + f"| `{collector['id']}` | `{collector['kind']}` | " + f"{'yes' if collector['valid'] else 'no'} | {modes} | {selection} |" + ) + rows.append("") + return "\n".join(rows) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 1e7a2b2c..c3373d12 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1868,7 +1868,7 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None "performance": ("standard", 1, False, False, True), } self.assertEqual(set(expected), set(EXECUTION_MODES)) - self.assertEqual(set(expected), {item["name"] for item in mode_matrix()}) + self.assertEqual(set(expected), {item["name"] for item in mode_matrix()["modes"]}) for name, contract in expected.items(): with self.subTest(mode=name): request = _request(parser.parse_args(["run", "--mode", name]), 4) @@ -1913,8 +1913,10 @@ def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: ): self.assertEqual(0, main(["--modes"])) payload = json.loads(output.getvalue()) - self.assertEqual(list(EXECUTION_MODES), [item["name"] for item in payload]) - self.assertTrue(all("legacy_aliases" in item for item in payload)) + self.assertEqual(1, payload["schema_version"]) + self.assertEqual(list(EXECUTION_MODES), [item["name"] for item in payload["modes"]]) + self.assertTrue(all("legacy_aliases" in item for item in payload["modes"])) + self.assertEqual([], payload["collectors"]) def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: """An empty invocation is useful while contradictory legacy actions fail immediately.""" diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 068b725d..cb208e5f 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -7,6 +7,7 @@ from logicytics import ResourceClass from logicytics.discovery import preflight +from logicytics.modes import EXECUTION_MODES, mode_matrix class ShippedCollectorTests(unittest.TestCase): @@ -109,6 +110,36 @@ def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling(self) self.assertIs(ResourceClass.GENERAL, metadata.resource_class) self.assertEqual(("text/csv",), metadata.output_media_types) + def test_mode_matrix_represents_every_discovered_collector_exactly_once(self) -> None: + """Mode documentation covers selected, manual-only, and quarantined collectors.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + matrix = mode_matrix(report.candidates) + collector_rows = matrix["collectors"] + expected_ids = { + candidate.metadata.id if candidate.metadata is not None else candidate.selection_id + for candidate in report.candidates + } + represented_ids = [row["id"] for row in collector_rows] + self.assertEqual(expected_ids, set(represented_ids)) + self.assertEqual(len(represented_ids), len(set(represented_ids))) + + modes = {row["name"]: row for row in matrix["modes"]} + self.assertEqual(set(EXECUTION_MODES), set(modes)) + for collector in collector_rows: + with self.subTest(collector=collector["id"]): + self.assertEqual( + collector["valid"] and not collector["modes"], + collector["manual_only"], + ) + for mode_name in collector["modes"]: + self.assertIn(collector["id"], modes[mode_name]["collector_ids"]) + for mode_name, mode in modes.items(): + expected = { + row["id"] for row in collector_rows if mode_name in row["modes"] + } + self.assertEqual(expected, set(mode["collector_ids"])) + if __name__ == "__main__": unittest.main() From 33f12c9fa72c876c4131baa6fe418566d45f8c4c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:15:17 +0300 Subject: [PATCH 184/316] feat: launch maintenance actions in a new console Add an explicit Windows-only update workflow that launches one allowlisted preflight, debug, or developer action in a separate visible command window after a successful update check. Require paired selection flags, preserve the current interpreter and repository working directory, avoid shell parsing, document the workflow, and cover launch and rejection behavior with regression tests. --- README.md | 5 ++++ TODO.md | 2 +- logicytics/cli.py | 35 ++++++++++++++++++++++- tests/test_core_functionality.py | 49 +++++++++++++++++++++++++++++++- 4 files changed, 88 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5713a77e..e4b2b866 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ Run these from the repository root: python -m logicytics preflight python -m logicytics debug python -m logicytics dev +python -m logicytics update --launch-action debug --new-window python -m logicytics plan --profile standard python -m logicytics run --profile standard --acknowledge-authorization ``` @@ -134,6 +135,10 @@ version, and configured file-integrity diagnostics to integrity manifest and checks source organization without running collectors. Manifest changes are opt-in: use `dev --write-manifest --next-version 4.1.0`, or `dev --interactive` to review status markers and confirm the write. +On Windows, an update workflow can explicitly open a fresh visible console for +one allowlisted maintenance action with `update --launch-action preflight +--new-window`. Both options are required together; ordinary update checks never +launch another process. Collectors that request additional access must be explicitly approved, for example: diff --git a/TODO.md b/TODO.md index 09a9aae8..111b5be0 100644 --- a/TODO.md +++ b/TODO.md @@ -835,7 +835,7 @@ appropriate default/deep/non-Python mode. - [x] Verify that the project is a Git checkout. - [x] Pull the configured upstream repository when the user explicitly requests it. - [x] Report update success, failure, and repository-state errors. -- [ ] Support launching a selected action in a new command window when required by +- [x] Support launching a selected action in a new command window when required by the update/special-action workflow. ### Developer action (`--dev`) diff --git a/logicytics/cli.py b/logicytics/cli.py index fc4f5671..937689f4 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -249,6 +249,16 @@ def _parser() -> argparse.ArgumentParser: if command == "update": subparser.add_argument("--apply", action="store_true", help="Explicitly run git pull after repository checks.") + subparser.add_argument( + "--launch-action", + choices=("preflight", "debug", "dev"), + help="Select a safe maintenance action to launch after a successful update check.", + ) + subparser.add_argument( + "--new-window", + action="store_true", + help="Launch --launch-action in a visible, separate Windows command window.", + ) subparser.add_argument( "--performance-check", action="store_true", @@ -290,6 +300,23 @@ def _write_json(path: Path, payload: object) -> Path: return path +def _launch_action_window(root: Path, action: str) -> int: + """Launch one allowlisted maintenance action in a separate visible Windows console.""" + if sys.platform != "win32": + raise OSError("new command windows are supported only on Windows") + if action not in {"preflight", "debug", "dev"}: + raise ValueError(f"unsupported new-window action: {action}") + command = [sys.executable, "-m", "logicytics", action] + process = subprocess.Popen( + command, + cwd=root, + shell=False, + creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010), + close_fds=True, + ) + return process.pid + + def _status_marker(label: str, count: int) -> str: """Return a colored interactive repository-status marker when a terminal supports it.""" color = "\033[32m" if count == 0 else "\033[33m" @@ -440,6 +467,8 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if not report.invalid else 2 if arguments.command == "update": + if arguments.new_window != (arguments.launch_action is not None): + raise ValueError("--new-window and --launch-action must be provided together") git = subprocess.run(["git", "--version"], capture_output=True, check=False, text=True) is_repository = (root / ".git").exists() payload = {"git_available": git.returncode == 0, "git_version": git.stdout.strip() or None, @@ -451,8 +480,12 @@ def main(argv: list[str] | None = None) -> int: pulled = subprocess.run(["git", "pull"], cwd=root, capture_output=True, check=False, text=True) payload.update({"applied": True, "returncode": pulled.returncode, "stdout": pulled.stdout, "stderr": pulled.stderr}) + update_succeeded = not arguments.apply or payload.get("returncode") == 0 + if arguments.new_window and update_succeeded: + payload["launched_action"] = arguments.launch_action + payload["launched_process_id"] = _launch_action_window(root, arguments.launch_action) print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 if not arguments.apply or payload.get("returncode") == 0 else 1 + return 0 if update_succeeded else 1 if arguments.command == "dev": return _run_developer_action(root, configuration, arguments) plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index c3373d12..d8debdd8 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -30,7 +30,7 @@ ) from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.command_runner import parse_level_messages, run_command -from logicytics.cli import _parser, _request, main +from logicytics.cli import _launch_action_window, _parser, _request, main from logicytics.file_listing import list_files from logicytics.logging import ( ApplicationLogger, @@ -1932,6 +1932,53 @@ def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> _parser().parse_args(["run", "--default", "--performance-check"]) self.assertIn("not allowed with argument", errors.getvalue()) + def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self) -> None: + """The paired update options launch exactly one shell-free visible Windows action.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / ".git").mkdir() + git = subprocess.CompletedProcess(["git", "--version"], 0, "git version 2.0\n", "") + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch( + "logicytics.cli.subprocess.run", return_value=git + ), patch("logicytics.cli._launch_action_window", return_value=321) as launch, patch( + "sys.stdout", output + ): + self.assertEqual( + 0, + main(["update", "--launch-action", "debug", "--new-window"]), + ) + payload = json.loads(output.getvalue()) + self.assertEqual("debug", payload["launched_action"]) + self.assertEqual(321, payload["launched_process_id"]) + launch.assert_called_once_with(root, "debug") + + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", new_callable=io.StringIO + ) as invalid_output: + self.assertEqual(2, main(["update", "--new-window"])) + self.assertIn("must be provided together", invalid_output.getvalue()) + + def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> None: + """Visible maintenance windows preserve argument boundaries and repository cwd.""" + root = Path("C:/repo").resolve() + process = MagicMock(pid=42) + with patch("logicytics.cli.sys.platform", "win32"), patch( + "logicytics.cli.subprocess.Popen", return_value=process + ) as popen: + self.assertEqual(42, _launch_action_window(root, "preflight")) + popen.assert_called_once_with( + [sys.executable, "-m", "logicytics", "preflight"], + cwd=root, + shell=False, + creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010), + close_fds=True, + ) + with patch("logicytics.cli.sys.platform", "linux"), self.assertRaisesRegex( + OSError, "only on Windows" + ): + _launch_action_window(root, "debug") + def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: """Execution policy is selectable directly instead of relying on compatibility modes.""" parser = _parser() From 68a5cc9cf792cc5b32a4754e20153af45370d8e5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:21:40 +0300 Subject: [PATCH 185/316] feat: migrate historical INI configuration Load CODE/config.ini as a bounded read-only fallback when modern JSON is absent and translate the original logging, worker, history, flag, memory, network, and packet settings into validated v4 sections. Preserve modern JSON precedence, support fractional legacy sampling intervals, record migration provenance, reject malformed or unsafe values before planning, and retain the source file byte-for-byte. --- README.md | 5 ++ TODO.md | 2 +- core/network/bandwidth_sample.py | 10 ++- logicytics/configuration.py | 107 +++++++++++++++++++++++++++---- tests/test_core_functionality.py | 63 ++++++++++++++++++ 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e4b2b866..c1df7645 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,11 @@ file. Legacy `workers`/`worker_count`, `max_workers`, `output_root`, and the old `ACCESS/RUNS` default becomes `output/data`. Ambiguous aliases, unsafe values, unsupported versions, and implicit plugin-selection fields are rejected. Run manifests record `migrated_from_schema: 3` when this compatibility path runs. +When `logicytics.json` is absent, the historical `CODE/config.ini` is also loaded +as a read-only migration source. Its general logging/worker/history options and +Flag, DumpMemory, NetWorkPsutil, and PacketSniffer sections are converted into +the same bounded typed v4 sections. Modern JSON always takes precedence; an INI +larger than 2 MiB, malformed values, and unsafe limits fail before planning. ## Collector rules diff --git a/TODO.md b/TODO.md index 111b5be0..1018e359 100644 --- a/TODO.md +++ b/TODO.md @@ -654,7 +654,7 @@ Windows platform allows. ### Configuration -- [ ] Load `config.ini` from the project CODE directory. +- [x] Load `config.ini` from the project CODE directory. - [x] Expose debug level, version, current file manifest, log retention, worker count, preference persistence, and all collector settings. - [x] Support remote configuration retrieval for update/integrity checks. diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index 78d03688..b06ac9d6 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -20,6 +20,14 @@ def _setting(settings: object, key: str, default: int, maximum: int) -> int: return value if isinstance(value, int) and 1 <= value <= maximum else default +def _interval_setting(settings: object) -> float: + """Return the configured finite positive sampling interval.""" + value = settings.get("interval_seconds", _DEFAULT_INTERVAL_SECONDS) if isinstance(settings, dict) else _DEFAULT_INTERVAL_SECONDS + if isinstance(value, (int, float)) and not isinstance(value, bool) and 0.1 <= value <= 60: + return float(value) + return float(_DEFAULT_INTERVAL_SECONDS) + + def _is_access_denied(detail: str) -> bool: """Recognize common permission-denied wording from PowerShell output.""" normalized = detail.casefold() @@ -56,7 +64,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before bandwidth sampling") samples = _setting(context.settings, "sample_count", _DEFAULT_SAMPLES, 10) - interval = _setting(context.settings, "interval_seconds", _DEFAULT_INTERVAL_SECONDS, 5) + interval = _interval_setting(context.settings) command = "Get-NetAdapterStatistics | Select-Object Name, ReceivedBytes, SentBytes | ConvertTo-Json -Depth 3" observations: list[dict[str, dict[str, int]]] = [] context.report_progress("bandwidth_sample_started", sample_count=samples, interval_seconds=interval) diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 6be6b3dc..8cca6dce 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -5,6 +5,7 @@ import hashlib import json import re +import configparser from dataclasses import asdict, dataclass, field from math import isfinite from pathlib import Path @@ -15,6 +16,7 @@ from logicytics.redaction import redact_mapping SCHEMA_VERSION = 4 +MAXIMUM_CONFIGURATION_BYTES = 2 * 1024 * 1024 DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 _COLLECTOR_ID = re.compile( @@ -60,7 +62,7 @@ class CollectorSettingRule: _COLLECTOR_SETTING_SCHEMAS = MappingProxyType({ "core.network.bandwidth_sample": { "sample_count": CollectorSettingRule("integer", 1, 10), - "interval_seconds": CollectorSettingRule("integer", 1, 5), + "interval_seconds": CollectorSettingRule("number", 0.1, 60), }, "core.packet.packet_capture": { "packet_count": CollectorSettingRule("integer", 1, 10_000), @@ -204,6 +206,79 @@ def _migrate_v3_configuration(raw: Mapping[str, Any], project_root: Path) -> dic } +def _legacy_ini_payload(path: Path) -> dict[str, Any]: + """Translate the bounded historical CODE/config.ini schema into typed v4 sections.""" + try: + payload = path.read_bytes() + except OSError as error: + raise PlanError(f"invalid legacy configuration file {path}: {error}") from error + if len(payload) > MAXIMUM_CONFIGURATION_BYTES: + raise PlanError("legacy configuration exceeds the 2 MiB limit") + try: + text = payload.decode("utf-8-sig") + parser = configparser.ConfigParser(interpolation=None, strict=True) + parser.read_string(text, source=str(path)) + except (UnicodeDecodeError, configparser.Error) as error: + raise PlanError(f"invalid legacy configuration file {path}: {error}") from error + + try: + runtime: dict[str, Any] = {} + logging: dict[str, Any] = {} + interaction: dict[str, Any] = {} + collectors: dict[str, dict[str, Any]] = {} + if parser.has_option("Settings", "max_workers"): + workers = parser.getint("Settings", "max_workers") + runtime.update({"default_max_workers": workers, "maximum_workers": workers}) + if parser.has_option("Settings", "log_using_debug"): + logging["level"] = "DEBUG" if parser.getboolean("Settings", "log_using_debug") else "INFO" + if parser.has_option("Settings", "delete_old_logs"): + logging["delete_previous"] = parser.getboolean("Settings", "delete_old_logs") + if parser.has_option("Settings", "save_preferences"): + interaction["history_enabled"] = parser.getboolean("Settings", "save_preferences") + + if parser.has_section("Flag Settings"): + if parser.has_option("Flag Settings", "accuracy_min"): + interaction["similarity_threshold"] = parser.getfloat("Flag Settings", "accuracy_min") / 100 + if parser.has_option("Flag Settings", "model_to_use"): + interaction["model_name"] = parser.get("Flag Settings", "model_to_use") + if parser.has_option("Flag Settings", "model_debug"): + interaction["model_debug"] = parser.getboolean("Flag Settings", "model_debug") + + if parser.has_section("DumpMemory Settings"): + limit_mib = parser.getint("DumpMemory Settings", "file_size_limit", fallback=0) + safety_factor = parser.getfloat("DumpMemory Settings", "file_size_safety", fallback=1.0) + if limit_mib < 0 or safety_factor < 1: + raise ValueError("memory limits require file_size_limit >= 0 and file_size_safety >= 1") + output_limit = min(64 * 1024 * 1024, limit_mib * 1024 * 1024) if limit_mib else 64 * 1024 * 1024 + collectors["core.process.memory_map"] = { + "output_limit_bytes": output_limit, + "disk_safety_margin_bytes": int(output_limit * (safety_factor - 1)), + "dump_directory": "memory_maps", + } + + if parser.has_section("NetWorkPsutil Settings"): + collectors["core.network.bandwidth_sample"] = { + "sample_count": parser.getint("NetWorkPsutil Settings", "sample_count", fallback=3), + "interval_seconds": parser.getfloat("NetWorkPsutil Settings", "interval", fallback=1.0), + } + + if parser.has_section("PacketSniffer Settings"): + collectors["core.packet.packet_capture"] = { + "interface": parser.get("PacketSniffer Settings", "interface", fallback="WiFi"), + "packet_count": parser.getint("PacketSniffer Settings", "packet_count", fallback=100), + "timeout_seconds": parser.getfloat("PacketSniffer Settings", "timeout", fallback=10), + "retry_window_seconds": parser.getfloat("PacketSniffer Settings", "max_retry_time", fallback=0), + } + except (configparser.Error, ValueError) as error: + raise PlanError(f"invalid legacy configuration file {path}: {error}") from error + return { + "schema_version": SCHEMA_VERSION, + "runtime": runtime, + "interaction": interaction, + "maintenance": {}, + "logging": logging, + "collectors": collectors, + } @dataclass(frozen=True, slots=True) class RuntimeSettings: """Engine-wide limits that apply before a collector is started.""" @@ -288,25 +363,31 @@ def default_config(project_root: Path) -> AppConfig: def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: - """Load and validate `logicytics.json`, or use the documented defaults.""" - path = config_path or project_root / "logicytics.json" + """Load typed JSON, migrate historical CODE/config.ini, or use safe defaults.""" + modern_path = project_root / "logicytics.json" + legacy_path = project_root / "CODE" / "config.ini" + path = config_path or (modern_path if modern_path.exists() else legacy_path) if not path.exists(): return default_config(project_root) - try: - raw = json.loads( - path.read_text(encoding="utf-8"), - object_pairs_hook=_unique_json_object, - parse_constant=_reject_json_constant, - parse_float=_finite_json_number, - ) - except (OSError, UnicodeDecodeError, ValueError) as error: - raise PlanError(f"invalid configuration file {path}: {error}") from error + legacy_ini = path.suffix.casefold() == ".ini" + if legacy_ini: + raw = _legacy_ini_payload(path) + else: + try: + raw = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_unique_json_object, + parse_constant=_reject_json_constant, + parse_float=_finite_json_number, + ) + except (OSError, UnicodeDecodeError, ValueError) as error: + raise PlanError(f"invalid configuration file {path}: {error}") from error if not isinstance(raw, dict): raise PlanError("configuration root must be a JSON object") schema_version = raw.get("schema_version", SCHEMA_VERSION) if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise PlanError("configuration schema_version must be an integer") - migrated_from_schema: int | None = None + migrated_from_schema: int | None = 3 if legacy_ini else None if schema_version == 3: raw = _migrate_v3_configuration(raw, project_root) migrated_from_schema = schema_version diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d8debdd8..c041b07d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1524,6 +1524,69 @@ def test_legacy_configuration_migrates_runtime_and_collector_aliases_without_wri self.assertEqual(settings["core.packet.packet_capture"], configuration.settings_for("core.packet.packet_capture")) self.assertEqual(original, config_path.read_text(encoding="utf-8")) + + def test_historical_code_config_ini_migrates_into_typed_v4_settings(self) -> None: + """The original CODE/config.ini is a bounded read-only fallback when JSON is absent.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + code = root / "CODE" + code.mkdir() + config_path = code / "config.ini" + original = """[Settings] +log_using_debug = true +delete_old_logs = true +max_workers = 6 +save_preferences = false + +[Flag Settings] +accuracy_min = 30.0 +model_to_use = all-MiniLM-L6-v2 +model_debug = true + +[DumpMemory Settings] +file_size_limit = 8 +file_size_safety = 1.5 + +[NetWorkPsutil Settings] +sample_count = 5 +interval = 1.5 + +[PacketSniffer Settings] +interface = WiFi +packet_count = 5000 +timeout = 10 +max_retry_time = 30 +""" + config_path.write_text(original, encoding="utf-8") + + configuration = load_config(root) + + self.assertEqual(3, configuration.migrated_from_schema) + self.assertEqual(6, configuration.runtime.default_max_workers) + self.assertEqual(6, configuration.runtime.maximum_workers) + self.assertEqual("DEBUG", configuration.logging.level) + self.assertTrue(configuration.logging.delete_previous) + self.assertFalse(configuration.interaction.history_enabled) + self.assertEqual(0.3, configuration.interaction.similarity_threshold) + self.assertEqual("all-MiniLM-L6-v2", configuration.interaction.model_name) + self.assertTrue(configuration.interaction.model_debug) + self.assertEqual( + { + "output_limit_bytes": 8 * 1024 * 1024, + "disk_safety_margin_bytes": 4 * 1024 * 1024, + "dump_directory": "memory_maps", + }, + configuration.settings_for("core.process.memory_map"), + ) + self.assertEqual( + {"sample_count": 5, "interval_seconds": 1.5}, + configuration.settings_for("core.network.bandwidth_sample"), + ) + self.assertEqual(5000, configuration.settings_for("core.packet.packet_capture")["packet_count"]) + self.assertEqual(original, config_path.read_text(encoding="utf-8")) + + (root / "logicytics.json").write_text('{"schema_version":4}', encoding="utf-8") + self.assertIsNone(load_config(root).migrated_from_schema) self.assertFalse((root / "output").exists()) self.assertEqual(3, configuration.to_manifest_dict()["migrated_from_schema"]) From dc706771f96e45dbbdf3825a5fd057cba5a59302 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:25:34 +0300 Subject: [PATCH 186/316] feat: update legacy developer manifests Teach developer checks that core, plugins, tests, the package, and historical CODE are valid Python locations while continuing to report naming, docstring, and crowded-module violations. After explicit dev confirmation, atomically update only the historical config.ini version and file membership fields, preserve comments and unrelated sections, and retain hashed JSON manifests for modern checkouts. --- README.md | 4 +++ TODO.md | 4 +-- logicytics/cli.py | 9 ++++++- logicytics/maintenance.py | 41 +++++++++++++++++++++++++++++- tests/test_core_functionality.py | 43 ++++++++++++++++++++++++++++++++ 5 files changed, 97 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c1df7645..6a2ed79c 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ version, and configured file-integrity diagnostics to integrity manifest and checks source organization without running collectors. Manifest changes are opt-in: use `dev --write-manifest --next-version 4.1.0`, or `dev --interactive` to review status markers and confirm the write. +For a checkout still using the historical INI fallback, that same confirmed dev +action atomically updates only `[System Settings]` `version` and `files` in +`CODE/config.ini`, preserving comments and unrelated sections. Modern checkouts +continue to write the hashed JSON integrity manifest. On Windows, an update workflow can explicitly open a fresh visible console for one allowlisted maintenance action with `update --launch-action preflight --new-window`. Both options are required together; ordinary update checks never diff --git a/TODO.md b/TODO.md index 1018e359..145645d1 100644 --- a/TODO.md +++ b/TODO.md @@ -841,11 +841,11 @@ appropriate default/deep/non-Python mode. ### Developer action (`--dev`) - [x] Present contribution and repository-organization checks interactively. -- [ ] Check naming conventions, CODE placement, documentation/docstrings, and +- [x] Check naming conventions, CODE placement, documentation/docstrings, and one-main-feature-per-file organization. - [x] Compare current files against the configured manifest. - [x] Display added, removed, and unchanged files with colored status markers. -- [ ] Update the manifest in `config.ini` after confirmation. +- [x] Update the manifest in `config.ini` after confirmation. - [x] Prompt for and validate the next semantic version. - [x] Keep excluded history/cache/Sysinternals files out of manifest comparisons. diff --git a/logicytics/cli.py b/logicytics/cli.py index 937689f4..6549a2f5 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -27,6 +27,7 @@ load_local_manifest, local_version, maintenance_diagnostics, + write_legacy_ini_manifest, write_local_manifest, ) from logicytics.modes import ( @@ -362,7 +363,13 @@ def _run_developer_action( if next_version is None: raise ValueError("a semantic next version is required to write the integrity manifest") manifest = build_manifest(root, settings, next_version) - manifest_path = str(write_local_manifest(root, settings, manifest)) + legacy_ini = root / "CODE" / "config.ini" + modern_config = root / "logicytics.json" + manifest_path = str( + write_legacy_ini_manifest(root, manifest) + if legacy_ini.is_file() and not modern_config.exists() + else write_local_manifest(root, settings, manifest) + ) payload = { "checks": checks, "comparison": comparison, diff --git a/logicytics/maintenance.py b/logicytics/maintenance.py index 5f66141b..364e1738 100644 --- a/logicytics/maintenance.py +++ b/logicytics/maintenance.py @@ -207,6 +207,45 @@ def write_local_manifest( return path +def write_legacy_ini_manifest( + project_root: Path, + manifest: IntegrityManifest, +) -> Path: + """Atomically update only version and file membership in historical CODE/config.ini.""" + if _VERSION.fullmatch(manifest.version) is None: + raise ValueError("next version must use semantic versioning") + path = (project_root / "CODE" / "config.ini").resolve() + try: + path.relative_to(project_root.resolve()) + payload = path.read_bytes() + except (ValueError, OSError) as error: + raise ValueError(f"legacy config.ini is unavailable: {error}") from error + if len(payload) > _MAXIMUM_MANIFEST_BYTES: + raise ValueError("legacy config.ini exceeds the 2 MiB limit") + try: + text = payload.decode("utf-8-sig") + except UnicodeDecodeError as error: + raise ValueError(f"legacy config.ini is not valid UTF-8: {error}") from error + header = re.search(r"(?m)^\[System Settings\][ \t]*\r?$", text) + if header is None: + raise ValueError("legacy config.ini is missing [System Settings]") + following = re.search(r"(?m)^\[[^]\r\n]+\][ \t]*\r?$", text[header.end():]) + section_end = header.end() + following.start() if following is not None else len(text) + replacement = text[header.start():section_end] + files = ", ".join(sorted(manifest.files)) + updates = {"version": manifest.version, "files": f'"{files}"'} + for key, value in updates.items(): + pattern = re.compile(rf"(?m)^{re.escape(key)}\s*=.*$") + if pattern.search(replacement) is None: + raise ValueError(f"legacy config.ini [System Settings] is missing {key}") + replacement = pattern.sub(f"{key} = {value}", replacement, count=1) + updated = text[:header.start()] + replacement + text[section_end:] + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(updated, encoding="utf-8", newline="") + temporary.replace(path) + return path + + def compare_files( project_root: Path, settings: MaintenanceSettings, @@ -329,7 +368,7 @@ def developer_checks(project_root: Path, settings: MaintenanceSettings) -> dict[ continue if path.stem != "__init__" and re.fullmatch(r"[a-z][a-z0-9_]*", path.stem) is None: naming.append(relative.as_posix()) - if relative.parts[0] not in {"logicytics", "tests"} and len(relative.parts) > 1: + if relative.parts[0] not in {"CODE", "core", "logicytics", "plugins", "tests"}: misplaced_python.append(relative.as_posix()) try: tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(relative)) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index c041b07d..13c12ad5 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -462,6 +462,49 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No ) self.assertEqual("missing", debug["sysinternals"]["status"]) + def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> None: + """Legacy dev mode preserves comments and updates only its version and file list.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "pyproject.toml").write_text( + '[project]\nname = "fixture"\nversion = "4.0.0"\n', + encoding="utf-8", + ) + collector = root / "core" / "system" / "example.py" + collector.parent.mkdir(parents=True) + collector.write_text('"""Example collector module."""\n', encoding="utf-8") + code = root / "CODE" + code.mkdir() + legacy = code / "config.ini" + legacy.write_text( + "# preserve this comment\n" + "[System Settings]\n" + "version = 3.6.0\n" + 'files = "old.py"\n\n' + "[Unrelated]\n" + "value = untouched\n", + encoding="utf-8", + ) + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", output + ): + self.assertEqual( + 0, + main(["dev", "--write-manifest", "--next-version", "4.1.0"]), + ) + payload = json.loads(output.getvalue()) + self.assertEqual(str(legacy), payload["manifest_written"]) + self.assertEqual([], payload["checks"]["misplaced_python"]) + updated = legacy.read_text(encoding="utf-8") + self.assertIn("# preserve this comment", updated) + self.assertIn("version = 4.1.0", updated) + self.assertIn("CODE/config.ini", updated) + self.assertIn("core/system/example.py", updated) + self.assertIn("value = untouched", updated) + self.assertNotIn("old.py", updated) + self.assertFalse((root / "project.manifest.json").exists()) + def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> None: """Legacy scripts enter the pipeline only through typed metadata and worker isolation.""" with tempfile.TemporaryDirectory() as temporary: From 905b28d6645378ae05a6396170dfb0aab69327d7 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:30:30 +0300 Subject: [PATCH 187/316] feat: import generated legacy CODE evidence Add a deep-profile compatibility collector that streams bounded generated files from historical CODE directories into isolated workspaces and the canonical artifact catalog. Exclude source, scripts, executables, models, configuration, caches, virtual environments, and library internals; preserve nested evidence paths, enforce artifact limits and cancellation, document the policy, and verify exact inclusion behavior. --- README.md | 8 ++ TODO.md | 6 +- core/integration/legacy_code_outputs.py | 145 ++++++++++++++++++++ tests/test_legacy_code_outputs_collector.py | 85 ++++++++++++ tests/test_shipped_collectors.py | 1 + 5 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 core/integration/legacy_code_outputs.py create mode 100644 tests/test_legacy_code_outputs_collector.py diff --git a/README.md b/README.md index 6a2ed79c..5a38062b 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,14 @@ Flag, DumpMemory, NetWorkPsutil, and PacketSniffer sections are converted into the same bounded typed v4 sections. Modern JSON always takes precedence; an INI larger than 2 MiB, malformed values, and unsafe limits fail before planning. +Deep-profile compatibility includes `core.integration.legacy_code_outputs`. +When a historical `CODE/` directory exists, it imports only bounded generated +text, CSV, JSON, XML, HTML, DOT, SVG, registry, event-log, log, and ZIP evidence +through the normal artifact catalog. Python/PowerShell/batch source, executables, +models, `config.ini`, caches, virtual environments, and library internals are +never staged or packaged. Clean v4 checkouts skip this collector because they do +not need the legacy directory. + ## Collector rules Core collectors must live at `core//.py`. Each module diff --git a/TODO.md b/TODO.md index 145645d1..e08b6178 100644 --- a/TODO.md +++ b/TODO.md @@ -680,10 +680,10 @@ Windows platform allows. - [x] Create output/logs, output/logs/debug, output/logs/performance, output/data/zip, and output/data/hashes automatically. -- [ ] Collect generated files without including source code, executables, model +- [x] Collect generated files without including source code, executables, model files, configuration secrets, caches, or library internals in the evidence package. -- [ ] Include generated files from CODE in the package. +- [x] Include generated files from CODE in the package. - [x] Include MODS output in a separate MODS package during modded runs. - [x] Preserve nested directory structure inside packages. - [x] Name packages using the action and timestamp. @@ -921,7 +921,7 @@ appropriate default/deep/non-Python mode. flows. - [x] Verify that every collector is represented in the CLI mode matrix and the v4.0 documentation. -- [ ] Verify that packages contain generated evidence and metadata only. +- [x] Verify that packages contain generated evidence and metadata only. - [x] Verify that hashes reproduce from the final package bytes. - [ ] Update README, contribution guidance, configuration documentation, and the repository wiki for v4.0. diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py new file mode 100644 index 00000000..2f15b825 --- /dev/null +++ b/core/integration/legacy_code_outputs.py @@ -0,0 +1,145 @@ +"""Import generated evidence from historical CODE checkouts into the v4 artifact catalog.""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus + +_EVIDENCE_EXTENSIONS = { + ".csv", ".dot", ".evtx", ".html", ".htm", ".json", ".log", ".reg", + ".svg", ".txt", ".xml", ".zip", +} +_EXCLUDED_DIRECTORIES = { + ".git", ".idea", ".mypy_cache", ".pytest_cache", ".venv", "__pycache__", + "lib", "libs", "site-packages", "venv", +} +_MAXIMUM_FILES = 500 +_MAXIMUM_FILE_BYTES = 64 * 1024 * 1024 + + +class LegacyCodeOutputsCollector(CoreCollector): + """Import bounded generated files while excluding executable project material.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare the compatibility evidence import and its filesystem-read boundary.""" + return CollectorMetadata( + id="core.integration.legacy_code_outputs", + name="Legacy CODE generated outputs", + version="4.0.0", + specialty=Specialty.INTEGRATION, + output_media_types=("application/octet-stream",), + description="Imports bounded generated evidence from CODE without packaging source or configuration.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ,), + sensitive_data_categories=("legacy_generated_evidence",), + default_profiles=("deep",), + timeout_seconds=120, + maximum_output_bytes=512 * 1024 * 1024, + maximum_artifact_bytes=_MAXIMUM_FILE_BYTES, + maximum_artifact_files=_MAXIMUM_FILES, + ) + + @staticmethod + def _code_root() -> Path: + """Resolve the historical directory relative to this checked-in collector source.""" + return Path(__file__).resolve().parents[2] / "CODE" + + def validate(self, context: CollectorContext) -> ValidationResult: + """Skip cleanly when the historical CODE directory is not present.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + code_root = self._code_root() + if not code_root.is_dir() or code_root.is_symlink(): + return ValidationResult(False, reasons=("historical CODE directory is unavailable",)) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Stream allowlisted generated files into the private workspace and artifact store.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before legacy CODE import") + code_root = self._code_root().resolve() + candidates: list[Path] = [] + for path in sorted(code_root.rglob("*")): + if context.is_cancelled: + return CollectorResult.cancelled("cancelled during legacy CODE discovery") + relative = path.relative_to(code_root) + if any(part.casefold() in _EXCLUDED_DIRECTORIES for part in relative.parts): + continue + try: + if ( + not path.is_file() + or path.is_symlink() + or path.resolve().parent != (code_root / relative.parent).resolve() + or path.suffix.casefold() not in _EVIDENCE_EXTENSIONS + or path.stat().st_size > _MAXIMUM_FILE_BYTES + ): + continue + except OSError: + continue + candidates.append(path) + if len(candidates) >= _MAXIMUM_FILES: + break + + artifacts = [] + errors: list[str] = [] + for source in candidates: + if context.is_cancelled: + return CollectorResult.cancelled("cancelled during legacy CODE import", tuple(artifacts)) + relative = source.relative_to(code_root) + destination = context.workspace / "legacy_code" / relative + destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(destination.suffix + ".tmp") + try: + with source.open("rb") as source_stream, temporary.open("xb") as destination_stream: + while block := source_stream.read(1024 * 1024): + if context.is_cancelled: + raise InterruptedError("run cancellation was requested") + destination_stream.write(block) + temporary.replace(destination) + artifacts.append( + context.artifacts.register_file( + destination, + media_type="application/octet-stream", + evidence_kind=EvidenceKind.RAW, + transformations=("imported from historical CODE output",), + ) + ) + except InterruptedError: + temporary.unlink(missing_ok=True) + destination.unlink(missing_ok=True) + return CollectorResult.cancelled("cancelled during legacy CODE import", tuple(artifacts)) + except OSError as error: + temporary.unlink(missing_ok=True) + errors.append(f"{relative.as_posix()}: {error}") + if not artifacts and not errors: + return CollectorResult.skipped("no generated legacy CODE evidence matched the import policy") + if errors: + return CollectorResult.partial( + "legacy CODE evidence imported with file-level failures", + tuple(artifacts), + errors=tuple(errors), + ) + context.report_progress( + "legacy_code_outputs_finished", + imported_files=len(artifacts), + bytes_written=sum(artifact.size_bytes for artifact in artifacts), + ) + return CollectorResult.succeeded("legacy CODE evidence imported", tuple(artifacts)) + + def cleanup(self, context: CollectorContext) -> None: + """Remove only an unpublished temporary copy left by an interrupted import.""" + for temporary in context.workspace.rglob("*.tmp"): + temporary.unlink(missing_ok=True) diff --git a/tests/test_legacy_code_outputs_collector.py b/tests/test_legacy_code_outputs_collector.py new file mode 100644 index 00000000..d35a0f83 --- /dev/null +++ b/tests/test_legacy_code_outputs_collector.py @@ -0,0 +1,85 @@ +"""Behavioral coverage for importing generated evidence from historical CODE checkouts.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from core.integration.legacy_code_outputs import LegacyCodeOutputsCollector +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.contracts import CollectorContext, CollectorStatus + + +class LegacyCodeOutputsCollectorTests(unittest.TestCase): + """Prove that only bounded generated evidence reaches the artifact catalog.""" + + def test_generated_evidence_is_imported_while_project_material_is_excluded(self) -> None: + """Source, executables, models, configuration, caches, and libraries never enter artifacts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + code = root / "CODE" + code.mkdir() + (code / "report.txt").write_text("text evidence\n", encoding="utf-8") + nested = code / "reports" + nested.mkdir() + (nested / "events.csv").write_text("event_id\n1\n", encoding="utf-8") + excluded = { + "collector.py": "source", + "script.ps1": "source", + "tool.exe": "binary", + "model.pth": "model", + "config.ini": "secret configuration", + } + for name, contents in excluded.items(): + (code / name).write_text(contents, encoding="utf-8") + cache = code / "__pycache__" + cache.mkdir() + (cache / "cached.json").write_text("{}", encoding="utf-8") + library = code / "lib" + library.mkdir() + (library / "internal.log").write_text("internal", encoding="utf-8") + + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + cancellation = root / "cancel.requested" + metadata = LegacyCodeOutputsCollector.metadata() + writer = WorkspaceArtifactWriter( + metadata.id, + workspace, + artifact_root, + metadata.maximum_output_bytes, + metadata.maximum_artifact_files, + maximum_artifact_bytes=metadata.maximum_artifact_bytes, + ) + context = CollectorContext( + run_id="run-test", + collector_id=metadata.id, + workspace=workspace, + temporary_directory=workspace / "tmp", + artifacts=writer, + logger=MagicMock(), + settings={}, + cancellation_file=cancellation, + ) + collector = LegacyCodeOutputsCollector() + with patch.object(collector, "_code_root", return_value=code): + self.assertTrue(collector.validate(context).valid) + result = collector.collect(context) + + self.assertIs(CollectorStatus.SUCCEEDED, result.status) + self.assertEqual({"report.txt", "events.csv"}, {artifact.name for artifact in result.artifacts}) + stored = { + path.name for path in artifact_root.rglob("*") if path.is_file() + } + self.assertEqual({"report.txt", "events.csv"}, stored) + for name in excluded: + self.assertFalse(any(path.name == name for path in workspace.rglob("*"))) + self.assertFalse(any(path.name == name for path in artifact_root.rglob("*"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index cb208e5f..530d7250 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -75,6 +75,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.hardware.windows_features", "core.hardware.display_adapters", "core.hardware.battery_status", + "core.integration.legacy_code_outputs", "core.event_log.system_events", "core.event_log.application_events", "core.event_log.security_events", From df77d3994c227248b71e19975aa65e1cbad44da3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:36:52 +0300 Subject: [PATCH 188/316] feat: enforce MOD filesystem containment Run Python MODS through an audit-confined bootstrap that prevents configuration, built-in collector, and host filesystem mutations outside the private workspace unless filesystem_write is explicitly declared and approved. Require native PowerShell, batch, and executable MODS to declare that high-risk capability because their child-process writes cannot be Python-confined, preserve authoritative sidecar errors, document the boundary, and verify isolation and independent failure handling. --- MODS.md | 20 +++++-- README.md | 4 ++ TODO.md | 2 +- logicytics/contracts.py | 1 + logicytics/discovery.py | 10 +++- logicytics/mod_runner.py | 63 +++++++++++++++++++++ logicytics/runtime.py | 31 ++++++++++- tests/test_core_functionality.py | 96 +++++++++++++++++++++++++++++--- 8 files changed, 208 insertions(+), 19 deletions(-) create mode 100644 logicytics/mod_runner.py diff --git a/MODS.md b/MODS.md index a012c341..a160ffb9 100644 --- a/MODS.md +++ b/MODS.md @@ -31,7 +31,7 @@ This example is intentionally bounded and non-sensitive: "description": "Creates a bounded local compatibility report.", "author": "Example author", "supported_platforms": ["win32"], - "capabilities": ["subprocess"], + "capabilities": ["subprocess", "filesystem_write"], "privilege_level": "standard", "sensitive_data_categories": [], "network_access": "none", @@ -48,7 +48,13 @@ This example is intentionally bounded and non-sensitive: The ID must be `mod.`. Legacy adapters must request the `subprocess` capability, and the user must approve it with `--allow-capability subprocess`. Other access must also be declared and -approved. Invalid or missing sidecars quarantine the mod; selecting all mods +approved. Python MODs are audit-confined to their private workspace by default. +PowerShell, batch, and executable MODs must additionally declare +`filesystem_write` and the user must explicitly approve it with +`--allow-capability filesystem_write`, because Python cannot enforce filesystem +boundaries inside native child processes. A Python MOD needs that capability +only when intentionally writing outside its workspace. Invalid or missing +sidecars quarantine the mod; selecting all mods then fails preflight before any collector launches. ## Execution and output @@ -77,7 +83,9 @@ SHA-256 sidecar. Mods are untrusted, opt-in local code. Worker/process isolation, a private workspace, capability approval, bounded resources, packaging allowlists, and -process-tree termination contain engine state and evidence publication. They do -not grant access beyond the Windows account running Logicytics, nor do they -replace Windows ACLs or an AppContainer. Review a mod and its declared -capabilities before approving execution. +process-tree termination contain engine state and evidence publication. Python +MOD mutation attempts outside the workspace fail unless `filesystem_write` was +approved. Native MOD types require that explicit high-risk approval before +preflight accepts them. Approval does not grant access beyond the Windows +account running Logicytics, nor replace Windows ACLs or an AppContainer. Review +a mod and its declared capabilities before approving execution. diff --git a/README.md b/README.md index 5a38062b..d24ba8c8 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,10 @@ python -m logicytics run --profile standard --acknowledge-authorization ` Plugin collectors never join any profile unless explicitly included or enabled with `--plugins`. Unknown profile names are rejected before collection begins. +Python MODs are workspace-confined unless the user explicitly approves the +`filesystem_write` capability. Native PowerShell, batch, and executable MODs +must declare and receive that approval because their host writes cannot be +enforced through Python audit hooks. ## Execution modes diff --git a/TODO.md b/TODO.md index e08b6178..3b45e190 100644 --- a/TODO.md +++ b/TODO.md @@ -479,7 +479,7 @@ Windows platform allows. outputs. - [x] Give mods the same artifact writer, cancellation, timeout, logging, and result contract as built-in collectors. -- [ ] Prevent a mod from changing global configuration, replacing built-in +- [x] Prevent a mod from changing global configuration, replacing built-in collectors, or writing outside its permitted workspace without explicit permission. - [x] Preserve compatibility with existing script mods through an adapter layer, diff --git a/logicytics/contracts.py b/logicytics/contracts.py index b97f8a68..8d31a0f8 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -103,6 +103,7 @@ class Capability(StrEnum): """Explicitly approved platform access a collector may request.""" FILESYSTEM_READ = "filesystem_read" + FILESYSTEM_WRITE = "filesystem_write" REGISTRY_READ = "registry_read" SUBPROCESS = "subprocess" NETWORK = "network" diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 8d322f00..b9d56841 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -417,6 +417,14 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: candidate.runtime_error = f"mod ID must match its filename: {expected_id}" elif Capability.SUBPROCESS not in metadata.capabilities: candidate.runtime_error = "legacy script mods must declare the subprocess capability" + elif ( + candidate.execution_type != "mod_python" + and Capability.FILESYSTEM_WRITE not in metadata.capabilities + ): + candidate.runtime_error = ( + "non-Python mods must declare filesystem_write because their host mutations " + "cannot be audit-confined" + ) else: candidate.metadata = metadata return candidate @@ -721,7 +729,7 @@ def preflight(project_root: Path, *, configuration_hash: str = "unconfigured") - for candidate in candidates: if candidate.static_errors: continue - if candidate.kind is CollectorKind.MOD and candidate.metadata is not None: + if candidate.kind is CollectorKind.MOD: continue relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() entry = cached.get(relative_path) diff --git a/logicytics/mod_runner.py b/logicytics/mod_runner.py new file mode 100644 index 00000000..9f72652e --- /dev/null +++ b/logicytics/mod_runner.py @@ -0,0 +1,63 @@ +"""Audit-confined bootstrap for legacy Python MODS scripts.""" + +from __future__ import annotations + +import json +import runpy +import sys +from pathlib import Path + + +def main(argv: list[str] | None = None) -> int: + """Run one copied Python MOD with workspace mutation and capability enforcement.""" + arguments = sys.argv[1:] if argv is None else argv + if len(arguments) != 4: + print("Python MOD runner requires script, workspace, collector ID, and capabilities", file=sys.stderr) + return 2 + script = Path(arguments[0]).resolve() + workspace = Path(arguments[1]).resolve() + collector_id = arguments[2] + try: + capability_values = json.loads(arguments[3]) + if not isinstance(capability_values, list): + raise ValueError("capabilities must be a JSON list") + except (json.JSONDecodeError, ValueError) as error: + print(f"Invalid Python MOD capabilities: {error}", file=sys.stderr) + return 2 + + engine_root = Path(__file__).resolve().parent.parent + sys.path.insert(0, str(engine_root)) + from logicytics.contracts import Capability + from logicytics.runtime import _WorkerMutationGuard + + try: + capabilities = tuple(Capability(value) for value in capability_values) + except ValueError as error: + print(f"Invalid Python MOD capability: {error}", file=sys.stderr) + return 2 + try: + script.relative_to(workspace) + except ValueError: + print("Python MOD script must be copied inside its private workspace", file=sys.stderr) + return 2 + + guard = _WorkerMutationGuard( + workspace, + workspace / "published", + collector_id, + capabilities, + script, + ) + guard.active = True + previous_argv = sys.argv + try: + sys.argv = [str(script)] + runpy.run_path(str(script), run_name="__main__") + finally: + guard.active = False + sys.argv = previous_argv + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 3daf8c65..8e39f2db 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -123,6 +123,8 @@ def _check_path(self, value: object) -> None: if not isinstance(value, (str, bytes, os.PathLike)): raise PermissionError("collector filesystem mutation has an unsupported target") path = Path(os.fsdecode(value)).resolve() + if Capability.FILESYSTEM_WRITE in self.capabilities: + return if not any(path == root or root in path.parents for root in self.roots): raise PermissionError(f"collector filesystem mutation escapes its private workspace: {path}") @@ -284,10 +286,27 @@ def _result_from_dict(data: dict[str, object]) -> CollectorResult: ) -def _mod_command(script: Path, execution_type: str) -> list[str]: +def _mod_command( + script: Path, + execution_type: str, + workspace: Path | None = None, + collector_id: str = "mod.legacy", + capabilities: tuple[Capability, ...] = (), +) -> list[str]: """Build a shell-free command for one copied legacy MODS script.""" if execution_type == "mod_python": - return [sys.executable, str(script)] + if workspace is None: + raise ValueError("Python mod execution requires a private workspace") + runner = Path(__file__).with_name("mod_runner.py") + return [ + sys.executable, + "-I", + str(runner), + str(script), + str(workspace), + collector_id, + json.dumps([capability.value for capability in capabilities]), + ] if execution_type == "mod_powershell": return ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)] if execution_type == "mod_batch": @@ -332,7 +351,13 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu if cancellation_file.exists(): result = CollectorResult.cancelled("mod cancelled before execution") else: - command = _mod_command(copied_script, str(payload["execution_type"])) + command = _mod_command( + copied_script, + str(payload["execution_type"]), + workspace, + metadata.id, + metadata.capabilities, + ) environment = { "PATH": os.environ.get("PATH", ""), "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 13c12ad5..47d94541 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -142,7 +142,7 @@ def _plugin_collector_source() -> str: ) -def _mod_metadata(name: str) -> dict[str, object]: +def _mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, object]: """Return a complete sidecar declaration for a harmless legacy script fixture.""" return { "id": f"mod.{name}", @@ -152,7 +152,10 @@ def _mod_metadata(name: str) -> dict[str, object]: "description": "Harmless isolated legacy script fixture.", "author": "tests", "supported_platforms": [sys.platform], - "capabilities": ["subprocess"], + "capabilities": [ + "subprocess", + *(["filesystem_write"] if filesystem_write else []), + ], "privilege_level": "standard", "sensitive_data_categories": [], "network_access": "none", @@ -558,6 +561,80 @@ def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> self.assertTrue(any(name.endswith("/report.txt") for name in names)) self.assertIsNone(archive.testzip()) + def test_python_mod_cannot_mutate_project_configuration_without_write_approval(self) -> None: + """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + configuration_path = root / "logicytics.json" + configuration_path.write_text('{"schema_version":4}\n', encoding="utf-8") + mods = root / "MODS" + mods.mkdir() + scripts = { + "good.py": "from pathlib import Path\nPath('report.txt').write_text('ok', encoding='utf-8')\n", + "malicious.py": ( + "from pathlib import Path\n" + f"Path({str(configuration_path)!r}).write_text('replaced', encoding='utf-8')\n" + ), + } + for filename, source in scripts.items(): + script = mods / filename + script.write_text(source, encoding="utf-8") + script.with_suffix(".py.mod.json").write_text( + json.dumps(_mod_metadata(script.stem)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(2, len(report.valid), report.invalid) + plan = build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=(Capability.SUBPROCESS,), + acknowledge_authorization=True, + max_workers=2, + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + self.assertEqual("succeeded", records["mod.good"].status, records["mod.good"].errors) + self.assertEqual("failed", records["mod.malicious"].status) + self.assertTrue( + any("private workspace" in error for error in records["mod.malicious"].errors), + records["mod.malicious"].errors, + ) + self.assertEqual('{"schema_version":4}\n', configuration_path.read_text(encoding="utf-8")) + + def test_native_mod_requires_explicit_filesystem_write_declaration(self) -> None: + """Native child processes are quarantined unless their unconfined write risk is declared.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + script = mods / "native.bat" + script.write_text("@echo off\n", encoding="utf-8") + script.with_suffix(".bat.mod.json").write_text( + json.dumps(_mod_metadata("native")), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("filesystem_write", report.invalid[0].runtime_error) + + script.with_suffix(".bat.mod.json").write_text( + json.dumps(_mod_metadata("native", filesystem_write=True)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + with self.assertRaisesRegex(PlanError, "filesystem_write"): + build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) + def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers(self) -> None: """Compatibility modes include all MODS or only non-Python MODS deterministically.""" with tempfile.TemporaryDirectory() as temporary: @@ -568,16 +645,19 @@ def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers(self) - script = mods / f"{name}{extension}" script.write_text("pass\n" if extension == ".py" else "@echo off\n", encoding="utf-8") script.with_suffix(extension + ".mod.json").write_text( - json.dumps(_mod_metadata(name)), + json.dumps(_mod_metadata(name, filesystem_write=extension != ".py")), encoding="utf-8", ) report = preflight(root) self.assertEqual(2, len(report.valid), report.invalid) - modded = build_plan(report, RunRequest(enable_mods=True, approved_capabilities=(Capability.SUBPROCESS,))) + modded = build_plan(report, RunRequest( + enable_mods=True, + approved_capabilities=(Capability.SUBPROCESS, Capability.FILESYSTEM_WRITE), + )) nopy = build_plan(report, RunRequest( enable_mods=True, non_python_only=True, - approved_capabilities=(Capability.SUBPROCESS,), + approved_capabilities=(Capability.SUBPROCESS, Capability.FILESYSTEM_WRITE), )) self.assertEqual(["mod.batch_mod", "mod.python_mod"], [item.metadata.id for item in modded.collectors]) self.assertEqual(["mod.batch_mod"], [item.metadata.id for item in nopy.collectors]) @@ -602,7 +682,7 @@ def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files(s script = mods / filename script.write_text(contents, encoding="utf-8") script.with_suffix(script.suffix + ".mod.json").write_text( - json.dumps(_mod_metadata(script.stem)), + json.dumps(_mod_metadata(script.stem, filesystem_write=True)), encoding="utf-8", ) executable = Path(os.environ.get("WINDIR", r"C:\Windows")) / "System32" / "whoami.exe" @@ -610,7 +690,7 @@ def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files(s copied_executable = mods / "identity_mod.exe" shutil.copy2(executable, copied_executable) copied_executable.with_suffix(".exe.mod.json").write_text( - json.dumps(_mod_metadata("identity_mod")), + json.dumps(_mod_metadata("identity_mod", filesystem_write=True)), encoding="utf-8", ) report = preflight(root) @@ -620,7 +700,7 @@ def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files(s RunRequest( enable_mods=True, non_python_only=True, - approved_capabilities=(Capability.SUBPROCESS,), + approved_capabilities=(Capability.SUBPROCESS, Capability.FILESYSTEM_WRITE), acknowledge_authorization=True, max_workers=1, ), From 1ac904a10c51c9904e42c1e2eba0476e6e4d9289 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:39:47 +0300 Subject: [PATCH 189/316] test: enforce completed collector migration Audit every shipped core collector through its validated AST to require CollectorContext workspaces, artifact-catalog publication, and the absence of global or nonlocal mutable state. Document the compatibility-adapter boundary for legacy scripts and CODE evidence, establish the run artifact tree as canonical, and reconcile the completed migration requirements. --- README.md | 11 +++++++++++ TODO.md | 6 +++--- tests/test_shipped_collectors.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d24ba8c8..da09befe 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,17 @@ from `CoreCollector`, provide documented and typed `metadata`, `validate`, the equivalent `PluginCollector` contract. Every collector explicitly declares the exact MIME types it can register. + +## Migration status + +Historical Python, PowerShell, batch, and executable scripts enter v4 only as +sidecar-declared MOD compatibility collectors; they never bypass preflight, +planning, isolation, capability approval, or artifact registration. Historical +generated CODE evidence has its own bounded compatibility collector. Every +shipped core collector writes beneath `CollectorContext.workspace`, publishes +through `context.artifacts.register_file`, and avoids mutable `global`/`nonlocal` +state. The run artifact tree is canonical even when a compatibility collector +preserves a legacy evidence filename or nested relative path. Preflight statically compares literal `register_file(..., media_type=...)` calls with that declaration, and isolated workers enforce the declaration again on the returned artifact catalog. The collector folder, ID, class, description, diff --git a/TODO.md b/TODO.md index 3b45e190..d9e03f23 100644 --- a/TODO.md +++ b/TODO.md @@ -527,12 +527,12 @@ Windows platform allows. - [x] Keep existing command-line flags as compatibility aliases during migration. - [x] Map every legacy flag to a named v4.0 profile or explicit collector set. -- [ ] Wrap legacy scripts in compatibility collectors before rewriting their data +- [x] Wrap legacy scripts in compatibility collectors before rewriting their data gathering logic. -- [ ] Move one collector at a time from direct filesystem writes to artifact APIs. +- [x] Move one collector at a time from direct filesystem writes to artifact APIs. - [ ] Keep legacy output names as aliases or compatibility copies only where users rely on them; make the run artifact tree canonical. -- [ ] Remove global mutable state after all collectors use `RunContext`. +- [x] Remove global mutable state after all collectors use `RunContext`. - [x] Remove direct `exit()` calls, current-directory assumptions, nested worker pools, and package-wide cleanup from collectors. - [ ] Remove compatibility shims only after the v4.0 API and migration documentation diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 530d7250..daafa72d 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import unittest from pathlib import Path @@ -141,6 +142,34 @@ def test_mode_matrix_represents_every_discovered_collector_exactly_once(self) -> } self.assertEqual(expected, set(mode["collector_ids"])) + def test_every_core_collector_uses_context_artifacts_without_mutable_globals(self) -> None: + """Migration is complete only when all core modules use owned workspaces and catalogs.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + core_candidates = [ + candidate for candidate in report.valid if candidate.kind.value == "core" + ] + self.assertTrue(core_candidates) + for candidate in core_candidates: + with self.subTest(collector=candidate.metadata.id): + source = candidate.path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(candidate.path)) + self.assertFalse( + any(isinstance(node, (ast.Global, ast.Nonlocal)) for node in ast.walk(tree)), + "collectors must not declare shared mutable state", + ) + collect = next( + node for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name == "collect" + ) + attributes = { + node.attr for node in ast.walk(collect) if isinstance(node, ast.Attribute) + } + self.assertIn("workspace", attributes) + self.assertIn("artifacts", attributes) + self.assertIn("register_file", attributes) + if __name__ == "__main__": unittest.main() From c6397b6b7a9494e26356ff6b3421cf177c8f2d84 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:44:23 +0300 Subject: [PATCH 190/316] feat: add direct collector execution Add an explicit collector command that runs one exact collector ID and its declared dependencies through the normal authorization, isolation, lifecycle, and packaging pipeline. Reject conflicting profile and selection flags, document the workflow, and cover both standalone and dependency-aware execution with regression tests. --- README.md | 4 +++ TODO.md | 4 +-- logicytics/cli.py | 32 +++++++++++++++-- logicytics/contracts.py | 5 ++- logicytics/planner.py | 2 ++ tests/test_core_functionality.py | 60 ++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index da09befe..2a7b03c1 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ python -m logicytics preflight python -m logicytics debug python -m logicytics dev python -m logicytics update --launch-action debug --new-window +python -m logicytics collector core.system.system_info --acknowledge-authorization python -m logicytics plan --profile standard python -m logicytics run --profile standard --acknowledge-authorization ``` @@ -126,6 +127,9 @@ ZIP package and SHA-256 sidecar use the action, UTC request timestamp, and run ID, and are written into that run's `packages/` and `hashes/` directories; collector workspaces and `logs/` remain run-scoped. Explicit reruns use a `rerun--run-.zip` package identity. +`collector ` runs only that validated collector and any declared +dependencies through the same authorization, isolation, artifact, manifest, +packaging, and cleanup pipeline; it never adds unrelated profile members. Add `--interactive` to `run` when a transient command window should pause on the final status; noninteractive and automated runs never prompt. diff --git a/TODO.md b/TODO.md index d9e03f23..dc579abd 100644 --- a/TODO.md +++ b/TODO.md @@ -543,7 +543,7 @@ Windows platform allows. - [ ] Recreate Logicytics as a Windows system-data collection and packaging tool. - [ ] Preserve every supported collection category, execution mode, output type, configuration option, and extension point listed below. -- [ ] Keep collection modules independently runnable as well as runnable through +- [x] Keep collection modules independently runnable as well as runnable through the main orchestrator. - [ ] Make collection results predictable, timestamped, inspectable, and easy to verify after export. @@ -929,7 +929,7 @@ appropriate default/deep/non-Python mode. ## 9. v4.0 completion checklist - [ ] Every feature in this file has an implementation owner/status. -- [ ] Every collector can be run directly and through the orchestrator. +- [x] Every collector can be run directly and through the orchestrator. - [x] Every mode has an explicit collector inclusion matrix. - [ ] Every output has a documented path, format, and retention rule. - [x] A failed collector cannot silently make the run appear successful. diff --git a/logicytics/cli.py b/logicytics/cli.py index 6549a2f5..804fa958 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -116,10 +116,21 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: if unknown: raise ValueError(f"rerun collectors were not present in the original run: {', '.join(unknown)}") parent_run_id = previous["run_id"] + selection_only = getattr(arguments, "command", None) == "collector" + if selection_only and ( + arguments.include + or arguments.exclude + or arguments.profile is not None + or arguments.plugins + or arguments.mods + ): + raise ValueError("collector execution cannot combine its ID with profile or selection flags") + includes = (arguments.collector_id,) if selection_only else tuple(arguments.include) return RunRequest( profile=profile, - include=tuple(arguments.include), + include=includes, exclude=tuple(arguments.exclude), + selection_only=selection_only, enable_plugins=arguments.plugins, enable_mods=enable_mods, non_python_only=non_python_only, @@ -150,7 +161,7 @@ def _parser() -> argparse.ArgumentParser: parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") subcommands = parser.add_subparsers(dest="command") - for command in ("preflight", "debug", "update", "dev", "plan", "run"): + for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") subparser.add_argument( "--profile", @@ -247,6 +258,23 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="Pause at the final status so an interactive command window remains visible.", ) + if command == "collector": + subparser.add_argument("collector_id", help="Exact dotted ID of the collector to run independently.") + subparser.add_argument( + "--acknowledge-authorization", + action="store_true", + help="Confirm you are authorized to collect this collector's evidence.", + ) + subparser.add_argument( + "--no-package", + action="store_true", + help="Finalize the direct collector manifest without creating a ZIP package.", + ) + subparser.add_argument( + "--interactive", + action="store_true", + help="Pause at the final status so an interactive command window remains visible.", + ) if command == "update": subparser.add_argument("--apply", action="store_true", help="Explicitly run git pull after repository checks.") diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 8d31a0f8..dc626176 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -482,6 +482,7 @@ class RunRequest: profile: str = "standard" include: tuple[str, ...] = () exclude: tuple[str, ...] = () + selection_only: bool = False enable_plugins: bool = False enable_mods: bool = False non_python_only: bool = False @@ -515,11 +516,13 @@ def __post_init__(self) -> None: if self.rerun_from is not None and not self.include: raise ValueError("request rerun_from requires explicit included collector IDs") for name in ( - "enable_plugins", "enable_mods", "non_python_only", + "selection_only", "enable_plugins", "enable_mods", "non_python_only", "acknowledge_authorization", "performance_check", ): if not isinstance(getattr(self, name), bool): raise ValueError(f"request {name} must be boolean") + if self.selection_only and not self.include: + raise ValueError("request selection_only requires explicit included collector IDs") if not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or not 1 <= self.max_workers <= 64: raise ValueError("request max_workers must be an integer from 1 to 64") if self.performance_check and self.max_workers != 1: diff --git a/logicytics/planner.py b/logicytics/planner.py index 9b9e87a6..e2459b7d 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -47,6 +47,8 @@ def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> metadata = candidate.metadata if metadata.id in request.exclude: return False + if request.selection_only: + return metadata.id in request.include if metadata.id in request.include: return True if request.rerun_from is not None: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 47d94541..19e8f047 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -2040,6 +2040,66 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) + def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(self) -> None: + """Direct execution never pulls unrelated profile members into its supervised run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core = root / "core" / "system" + core.mkdir(parents=True) + (root / "plugins").mkdir() + (core / "first.py").write_text( + _delayed_collector_source("first", 0), + encoding="utf-8", + ) + (core / "second.py").write_text( + _delayed_collector_source("second", 0), + encoding="utf-8", + ) + output = io.StringIO() + with patch("logicytics.cli._project_root", return_value=root), patch( + "sys.stdout", output + ): + self.assertEqual( + 0, + main([ + "collector", + "core.system.first", + "--acknowledge-authorization", + ]), + ) + manifests = list((root / "output" / "data").glob("run-*/manifest.json")) + self.assertEqual(1, len(manifests)) + manifest = json.loads(manifests[0].read_text(encoding="utf-8")) + self.assertEqual( + ["core.system.first"], + [record["id"] for record in manifest["collectors"]], + ) + self.assertIn("Status: succeeded", output.getvalue()) + + dependent = core / "dependent.py" + dependent.write_text( + _delayed_collector_source( + "dependent", + 0, + dependencies=("core.system.second",), + ), + encoding="utf-8", + ) + report = preflight(root) + plan = build_plan( + report, + RunRequest( + include=("core.system.dependent",), + selection_only=True, + acknowledge_authorization=True, + max_workers=1, + ), + ) + self.assertEqual( + ["core.system.second", "core.system.dependent"], + [candidate.metadata.id for candidate in plan.collectors], + ) + def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" parser = _parser() From 78eb3618bd869f9b7738567d556e8fa1ec613771 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:53:49 +0300 Subject: [PATCH 191/316] feat: enforce stable collector output contracts Define canonical workspace and package path patterns, MIME formats, and retention behavior for all 65 shipped core collectors, and reject undeclared artifact names or formats inside isolated workers. Replace the timestamped Bluetooth history filename with a stable name, document the complete output rules, and add contract and artifact-writer regression coverage. --- OUTPUTS.md | 60 +++++++++++++++++++++++++++++ README.md | 3 ++ TODO.md | 4 +- core/bluetooth/bluetooth_history.py | 3 +- logicytics/artifacts.py | 14 +++++++ logicytics/output_contracts.py | 60 +++++++++++++++++++++++++++++ logicytics/runtime.py | 11 ++++++ tests/test_core_functionality.py | 29 ++++++++++++++ tests/test_shipped_collectors.py | 22 +++++++++++ 9 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 OUTPUTS.md create mode 100644 logicytics/output_contracts.py diff --git a/OUTPUTS.md b/OUTPUTS.md new file mode 100644 index 00000000..2f5a7671 --- /dev/null +++ b/OUTPUTS.md @@ -0,0 +1,60 @@ +# v4 output contract + +This document defines the stable names, formats, locations, and retention rules +for every shipped collector. `logicytics.output_contracts.core_output_contract` +is the executable source of truth and the worker rejects an artifact whose +relative path or MIME type is outside that contract. + +## Canonical paths + +For a collector ID `core..`, a fixed single-file output is named +`` in its private workspace and stored in the run catalog as +`artifacts/core__/`. In a verified ZIP it is +`evidence//core__/`, where `` is `raw` +for copied source evidence and `derived` for generated reports. + +The suffix is fixed by the declared MIME type: + +| MIME type | Suffix | +| --- | --- | +| `application/json` | `.json` | +| `application/xml` | `.xml` | +| `application/zip` | `.zip` | +| `text/csv` | `.csv` | +| `text/html` | `.html` | +| `text/plain` | `.txt` | +| `text/vnd.graphviz` | `.dot` | + +This rule covers every shipped collector except the explicitly listed contracts +below. MIME types remain those declared by each collector's `CollectorMetadata`. + +| Collector | Stable workspace path pattern | +| --- | --- | +| `core.bluetooth.paired_devices` | `bluetooth_devices.json` | +| `core.browser.browser_data_backup` | `browser_data/*/*/*` | +| `core.filesystem.sensitive_file_inventory` | `sensitive_file_inventory/**` | +| `core.integration.legacy_code_outputs` | `legacy_code/**` | +| `core.media.media_backup` | `media_backup/**` | +| `core.process.memory_map` | `**/memory_map.json` | +| `core.registry.hklm_backup` | `hklm_backup.reg` | +| `core.system.windows_system_data_backup` | `windows_system_data/*/*` | +| `core.wireless.wifi_profile_keys` | `wifi_profiles_with_keys/*.xml` | + +`core.bluetooth.bluetooth_history` uses the fixed +`bluetooth_history.json` name. Its content carries `collected_at`; the run ID and +package timestamp preserve snapshot history without making the output contract +time-dependent. + +## Retention + +Collector workspaces are private staging locations and are removed after durable +publication. The run tree, manifest, package, package hash, reports, and logs are +retained together for `general.retention_days` (30 days by default). Cleanup +removes only complete, run-owned directories older than that configured period. +Copied evidence has no separate lifetime: raw and derived artifacts follow their +owning run. A manifest-only run follows the same run-tree retention rule but has +no ZIP or package-hash sidecar. + +Global application, debug, and performance logs live under `output/logs/` and +follow the logging limits described in the configuration documentation; they are +not collector outputs and never enter the evidence catalog. diff --git a/README.md b/README.md index 2a7b03c1..3b3285de 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ records, the artifact catalog, errors, and package metadata. Readers fail closed on absent, non-integer, or unsupported schema versions. The manifest separately records package layout version `1.0` and every section path; the package SHA-256 remains an external sidecar because an archive cannot contain its own final digest. +Every shipped collector's stable filename or path pattern, MIME format, canonical +package location, and retention behavior is defined in [OUTPUTS.md](OUTPUTS.md) +and enforced by the isolated artifact writer. ## Requirements diff --git a/TODO.md b/TODO.md index dc579abd..6c6cf942 100644 --- a/TODO.md +++ b/TODO.md @@ -872,7 +872,7 @@ appropriate default/deep/non-Python mode. - [x] Recreate packet-sniffer settings for interface, packet count, timeout, and retry duration. - [x] Version the configuration schema and provide migration/default handling. -- [ ] Define stable output names and formats for every collector. +- [x] Define stable output names and formats for every collector. - [x] Keep logs, hashes, packages, graphs, CSVs, HTML, text, and copied evidence discoverable under one run-specific output tree. - [x] Include collection metadata: version, action, start/end times, host, user, @@ -931,7 +931,7 @@ appropriate default/deep/non-Python mode. - [ ] Every feature in this file has an implementation owner/status. - [x] Every collector can be run directly and through the orchestrator. - [x] Every mode has an explicit collector inclusion matrix. -- [ ] Every output has a documented path, format, and retention rule. +- [x] Every output has a documented path, format, and retention rule. - [x] A failed collector cannot silently make the run appear successful. - [x] A cancelled run leaves a recoverable partial-run report and cleans only its own temporary data. diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index a0a6ce7b..026d58ed 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -62,8 +62,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: errors=(str(error),)) snapshot = {"collected_at": datetime.now(timezone.utc).isoformat(), "devices": devices if isinstance(devices, list) else [devices]} - stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - output = context.workspace / f"bluetooth_history_{stamp}.json" + output = context.workspace / "bluetooth_history.json" output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") context.report_progress("bluetooth_history_finished", device_count=len(snapshot["devices"]), diff --git a/logicytics/artifacts.py b/logicytics/artifacts.py index 83bacc4d..4b79a390 100644 --- a/logicytics/artifacts.py +++ b/logicytics/artifacts.py @@ -2,6 +2,7 @@ from __future__ import annotations +import fnmatch import hashlib import os import re @@ -47,6 +48,8 @@ def __init__( maximum_artifact_bytes: int | None = None, run_output_budget_bytes: int | None = None, cancellation_file: Path | None = None, + allowed_relative_paths: tuple[str, ...] | None = None, + allowed_media_types: tuple[str, ...] | None = None, ) -> None: self._collector_id = collector_id collector_parts = collector_id.split(".", 2) @@ -76,6 +79,8 @@ def __init__( ): raise ArtifactError("run_output_budget_bytes must be a positive integer") self._cancellation_file = cancellation_file + self._allowed_relative_paths = allowed_relative_paths + self._allowed_media_types = allowed_media_types self._registration_lock = RLock() self._bytes_registered = 0 self._artifacts: list[Artifact] = [] @@ -116,6 +121,8 @@ def _register_file( media_type, ): raise ArtifactError("artifact media_type must be a valid MIME type") + if self._allowed_media_types is not None and media_type not in self._allowed_media_types: + raise ArtifactError(f"artifact media type is outside the collector output contract: {media_type}") if not isinstance(evidence_kind, EvidenceKind): raise ArtifactError("artifact evidence_kind must be an EvidenceKind value") if not isinstance(transformations, tuple) or any( @@ -133,6 +140,13 @@ def _register_file( raise ArtifactError("collector artifact count exceeds its declared maximum_artifact_files") relative_source = source.relative_to(self._workspace) + if self._allowed_relative_paths is not None and not any( + fnmatch.fnmatchcase(relative_source.as_posix(), pattern) + for pattern in self._allowed_relative_paths + ): + raise ArtifactError( + f"artifact path is outside the collector output contract: {relative_source.as_posix()}" + ) safe_collector_id = self._collector_id.replace(".", "_") destination = self._artifact_root / safe_collector_id / relative_source if not _is_within(destination, self._artifact_root): diff --git a/logicytics/output_contracts.py b/logicytics/output_contracts.py new file mode 100644 index 00000000..528d7ac0 --- /dev/null +++ b/logicytics/output_contracts.py @@ -0,0 +1,60 @@ +"""Stable artifact names and retention rules for shipped v4 core collectors.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from logicytics.contracts import CollectorMetadata + +_MEDIA_SUFFIX = { + "application/json": ".json", + "application/xml": ".xml", + "application/zip": ".zip", + "text/csv": ".csv", + "text/html": ".html", + "text/plain": ".txt", + "text/vnd.graphviz": ".dot", +} + +_SPECIAL_PATTERNS: dict[str, tuple[str, ...]] = { + "core.bluetooth.paired_devices": ("bluetooth_devices.json",), + "core.browser.browser_data_backup": ("browser_data/*/*/*",), + "core.filesystem.sensitive_file_inventory": ("sensitive_file_inventory/**",), + "core.integration.legacy_code_outputs": ("legacy_code/**",), + "core.media.media_backup": ("media_backup/**",), + "core.process.memory_map": ("**/memory_map.json",), + "core.registry.hklm_backup": ("hklm_backup.reg",), + "core.system.windows_system_data_backup": ("windows_system_data/*/*",), + "core.wireless.wifi_profile_keys": ("wifi_profiles_with_keys/*.xml",), +} + + +@dataclass(frozen=True, slots=True) +class OutputContract: + """Canonical workspace paths, formats, package paths, and retention for one collector.""" + + collector_id: str + workspace_patterns: tuple[str, ...] + media_types: tuple[str, ...] + retention: str = "run_retention_days" + + @property + def package_patterns(self) -> tuple[str, ...]: + """Return canonical evidence paths used in verified packages.""" + owner = self.collector_id.replace(".", "_") + return tuple(f"evidence/{{kind}}/{owner}/{pattern}" for pattern in self.workspace_patterns) + + +def core_output_contract(metadata: CollectorMetadata) -> OutputContract: + """Derive the explicit v4 output contract for one shipped core collector.""" + if not metadata.id.startswith("core."): + raise ValueError("stable built-in output contracts apply only to core collectors") + patterns = _SPECIAL_PATTERNS.get(metadata.id) + if patterns is None: + if len(metadata.output_media_types) != 1: + raise ValueError(f"{metadata.id} needs an explicit multi-format output contract") + suffix = _MEDIA_SUFFIX.get(metadata.output_media_types[0]) + if suffix is None: + raise ValueError(f"{metadata.id} needs an explicit output filename") + patterns = (f"{metadata.id.rsplit('.', 1)[-1]}{suffix}",) + return OutputContract(metadata.id, patterns, metadata.output_media_types) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 8e39f2db..61a9ca83 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -46,6 +46,7 @@ from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.packaging import package_manifest from logicytics.output_layout import ensure_output_layout +from logicytics.output_contracts import core_output_contract from logicytics.planner import RunPlan @@ -449,6 +450,14 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): collector = _load_collector(Path(str(payload["path"])), str(payload["expected_class"])) metadata = collector.metadata() + source_path = Path(str(payload["path"])).resolve() + shipped_core_root = (Path(__file__).resolve().parent.parent / "core").resolve() + try: + source_path.relative_to(shipped_core_root) + except ValueError: + output_contract = None + else: + output_contract = core_output_contract(metadata) writer = WorkspaceArtifactWriter( metadata.id, workspace, @@ -463,6 +472,8 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu maximum_artifact_bytes=metadata.maximum_artifact_bytes, run_output_budget_bytes=int(payload["run_output_budget_bytes"]), cancellation_file=Path(str(payload["cancellation_file"])), + allowed_relative_paths=(output_contract.workspace_patterns if output_contract else None), + allowed_media_types=(output_contract.media_types if output_contract else None), ) context = CollectorContext( run_id=str(payload["run_id"]), diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 19e8f047..2794d480 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -2345,6 +2345,35 @@ def test_artifact_destination_symlink_cannot_escape_run_store(self) -> None: writer.register_file(source) self.assertEqual([], list(outside.iterdir())) + def test_artifact_writer_enforces_declared_output_name_and_format(self) -> None: + """A core collector cannot silently drift from its published output contract.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + expected = workspace / "system_info.json" + unexpected = workspace / "renamed.json" + expected.write_text("{}\n", encoding="utf-8") + unexpected.write_text("{}\n", encoding="utf-8") + writer = WorkspaceArtifactWriter( + "core.system.system_info", + workspace, + artifact_root, + 1024, + 2, + allowed_relative_paths=("system_info.json",), + allowed_media_types=("application/json",), + ) + + with self.assertRaisesRegex(ArtifactError, "output contract"): + writer.register_file(unexpected, media_type="application/json") + with self.assertRaisesRegex(ArtifactError, "media type"): + writer.register_file(expected, media_type="text/plain") + artifact = writer.register_file(expected, media_type="application/json") + self.assertEqual("core_system_system_info/system_info.json", artifact.relative_path) + def test_cancelled_artifact_copy_never_publishes_partial_evidence(self) -> None: """Cancellation must stop staging without creating a final or temporary artifact.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index daafa72d..7869514f 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -9,6 +9,7 @@ from logicytics import ResourceClass from logicytics.discovery import preflight from logicytics.modes import EXECUTION_MODES, mode_matrix +from logicytics.output_contracts import core_output_contract class ShippedCollectorTests(unittest.TestCase): @@ -170,6 +171,27 @@ def test_every_core_collector_uses_context_artifacts_without_mutable_globals(sel self.assertIn("artifacts", attributes) self.assertIn("register_file", attributes) + def test_every_core_collector_has_a_stable_output_contract(self) -> None: + """Every shipped output has explicit names, formats, package paths, and retention.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + for candidate in report.valid: + if candidate.kind.value != "core" or candidate.metadata is None: + continue + with self.subTest(collector=candidate.metadata.id): + contract = core_output_contract(candidate.metadata) + self.assertEqual(candidate.metadata.output_media_types, contract.media_types) + self.assertTrue(contract.workspace_patterns) + self.assertTrue(all( + "\\" not in pattern and not pattern.startswith("/") + for pattern in contract.workspace_patterns + )) + self.assertEqual(len(contract.workspace_patterns), len(contract.package_patterns)) + self.assertTrue(all( + path.startswith("evidence/{kind}/core_") for path in contract.package_patterns + )) + self.assertEqual("run_retention_days", contract.retention) + if __name__ == "__main__": unittest.main() From 10e2056b8422923d90049c6ecba18a50e47cffdc Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 09:58:26 +0300 Subject: [PATCH 192/316] test: add golden output format coverage Add byte-stable fixtures for text, CSV, HTML, Graphviz DOT, and normalized manifest JSON so format drift is detected across every promised structured output family. Extract deterministic graph rendering and preserve command text newlines exactly, fixing a Windows newline translation defect exposed by the new golden tests. --- TODO.md | 2 +- core/packet/connection_graph.py | 48 ++++++----- core/system/system_details.py | 2 +- tests/golden/bios_info.html | 17 ++++ tests/golden/connection_graph.dot | 5 ++ tests/golden/manifest.json | 69 ++++++++++++++++ tests/golden/running_processes.csv | 3 + tests/golden/system_details.txt | 3 + tests/test_golden_outputs.py | 128 +++++++++++++++++++++++++++++ 9 files changed, 255 insertions(+), 22 deletions(-) create mode 100644 tests/golden/bios_info.html create mode 100644 tests/golden/connection_graph.dot create mode 100644 tests/golden/manifest.json create mode 100644 tests/golden/running_processes.csv create mode 100644 tests/golden/system_details.txt create mode 100644 tests/test_golden_outputs.py diff --git a/TODO.md b/TODO.md index 6c6cf942..9bbefe62 100644 --- a/TODO.md +++ b/TODO.md @@ -518,7 +518,7 @@ Windows platform allows. partial-failure, rerun, and cleanup behavior. - [x] Add packaging tests proving that unregistered files cannot enter a package and that manifest hashes match the final bytes. -- [ ] Add golden-output tests for stable text, CSV, HTML, graph, and manifest +- [x] Add golden-output tests for stable text, CSV, HTML, graph, and manifest formats, allowing platform-specific values to be normalized. - [ ] Add end-to-end Windows tests for permissions, PowerShell, registry, WMI, event logs, BitLocker, networking, and Sysinternals where available. diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index ba3aec4a..3894ff61 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -15,6 +15,30 @@ def _is_access_denied(detail: str) -> bool: return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) +def _render_connection_graph(command_output: str) -> str: + """Normalize netstat rows into deterministic, sorted Graphviz DOT source.""" + edges: set[tuple[str, str, str]] = set() + for line in command_output.splitlines(): + fields = line.split() + if len(fields) < 4 or fields[0].upper() not in {"TCP", "UDP"}: + continue + protocol, source, destination = fields[:3] + if destination in {"*:*", "*"}: + continue + edges.add((source, destination, protocol.upper())) + + def quote(value: str) -> str: + return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' + + lines = ["digraph connection_graph {", " rankdir=LR;"] + lines.extend( + f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" + for source, destination, protocol in sorted(edges) + ) + lines.append("}") + return "\n".join(lines) + "\n" + + class ConnectionGraphCollector(CoreCollector): """Build a DOT connection graph without retaining packet payloads.""" @@ -51,28 +75,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.SKIPPED, "connection graph access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "connection graph query failed", errors=(detail,)) - edges: set[tuple[str, str, str]] = set() - for line in completed.stdout.splitlines(): - fields = line.split() - if len(fields) < 4 or fields[0].upper() not in {"TCP", "UDP"}: - continue - protocol, source, destination = fields[:3] - if destination in {"*:*", "*"}: - continue - edges.add((source, destination, protocol.upper())) - - def quote(value: str) -> str: - return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' - - lines = ["digraph connection_graph {", " rankdir=LR;"] - lines.extend( - f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" for source, destination, protocol in - sorted(edges)) - lines.append("}") + rendered = _render_connection_graph(completed.stdout) output = context.workspace / "connection_graph.dot" - output.write_text("\n".join(lines) + "\n", encoding="utf-8") + output.write_text(rendered, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/vnd.graphviz") - context.report_progress("connection_graph_finished", edge_count=len(edges), bytes_written=artifact.size_bytes) + edge_count = sum(1 for line in rendered.splitlines() if " -> " in line) + context.report_progress("connection_graph_finished", edge_count=edge_count, bytes_written=artifact.size_bytes) return CollectorResult.succeeded("connection graph collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/system/system_details.py b/core/system/system_details.py index 07be8196..a6b90181 100644 --- a/core/system/system_details.py +++ b/core/system/system_details.py @@ -74,7 +74,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: ) return CollectorResult(CollectorStatus.FAILED, "systeminfo failed", errors=(detail,)) output = context.workspace / "system_details.txt" - output.write_text(completed.stdout, encoding="utf-8") + output.write_text(completed.stdout, encoding="utf-8", newline="") artifact = context.artifacts.register_file(output, media_type="text/plain") line_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) context.report_progress("system_details_finished", line_count=line_count, bytes_written=artifact.size_bytes) diff --git a/tests/golden/bios_info.html b/tests/golden/bios_info.html new file mode 100644 index 00000000..e05c0e3c --- /dev/null +++ b/tests/golden/bios_info.html @@ -0,0 +1,17 @@ + + + BIOS information + +

BIOS information

+ + + + + + + + + +
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
+ + diff --git a/tests/golden/connection_graph.dot b/tests/golden/connection_graph.dot new file mode 100644 index 00000000..975a9bd1 --- /dev/null +++ b/tests/golden/connection_graph.dot @@ -0,0 +1,5 @@ +digraph connection_graph { + rankdir=LR; + "10.0.0.5:50000" -> "1.1.1.1:443" [label="TCP"]; + "[::1]:5353" -> "[::1]:5354" [label="UDP"]; +} diff --git a/tests/golden/manifest.json b/tests/golden/manifest.json new file mode 100644 index 00000000..0e6c13c8 --- /dev/null +++ b/tests/golden/manifest.json @@ -0,0 +1,69 @@ +{ + "action": "run", + "artifact_catalog": [], + "cancellation_requested": false, + "collectors": [ + { + "artifacts": [], + "attempt_count": 0, + "duration_seconds": null, + "errors": [], + "event_count": 0, + "failure": null, + "finished_at": "", + "heartbeat_at": null, + "id": "core.system.system_info", + "isolation_mode": "process", + "last_progress_at": null, + "metrics": {}, + "peak_memory_bytes": 0, + "progress": { + "bytes_written": 0, + "elapsed_seconds": 0.0, + "events_processed": 0, + "files_copied": 0, + "files_scanned": 0, + "packets_observed": 0 + }, + "retry_history": [], + "source": "", + "started_at": "", + "status": "succeeded", + "summary": "collected", + "termination_reason": null, + "worker_exit_code": null, + "worker_pid": null + } + ], + "configuration": {"schema_version": 4}, + "engine_version": "4.0", + "errors": [], + "finished_at": "", + "host": { + "hostname": "", + "is_administrator": "false", + "platform": "win32", + "python": "", + "user": "" + }, + "manifest_schema_version": 1, + "package": null, + "package_layout_version": "1.0", + "package_sections": { + "derived_reports": "evidence/derived/", + "hashes": "hashes/", + "logs": "logs/", + "metadata": "metadata/", + "raw_evidence": "evidence/raw/", + "reports": "reports/" + }, + "parent_run_id": null, + "plan_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", + "request": {"profile": "minimal"}, + "requested_at": "", + "resolved_plan": ["core.system.system_info"], + "run_id": "run-00000000000000000000000000000000", + "skipped_collectors": [], + "status": "succeeded", + "total_artifact_bytes": 0 +} diff --git a/tests/golden/running_processes.csv b/tests/golden/running_processes.csv new file mode 100644 index 00000000..2b02d3ee --- /dev/null +++ b/tests/golden/running_processes.csv @@ -0,0 +1,3 @@ +"Image Name","PID","Session Name","Session#","Mem Usage" +"System Idle Process","0","Services","0","8 K" +"example.exe","4242","Console","1","12,345 K" diff --git a/tests/golden/system_details.txt b/tests/golden/system_details.txt new file mode 100644 index 00000000..1ce60f94 --- /dev/null +++ b/tests/golden/system_details.txt @@ -0,0 +1,3 @@ +Host Name: GOLDEN-HOST +OS Name: Microsoft Windows 11 Pro +System Type: x64-based PC diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py new file mode 100644 index 00000000..bdb2e24b --- /dev/null +++ b/tests/test_golden_outputs.py @@ -0,0 +1,128 @@ +"""Byte-stable golden tests for every structured v4 output family.""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest.mock import patch + +from core.packet.connection_graph import _render_connection_graph +from core.process import running_processes +from core.system import system_details +from core.system.bios_info import _render_bios_table +from logicytics.contracts import ( + Artifact, ArtifactWriter, CollectorContext, EvidenceKind, EventLogger, RunStatus, +) +from logicytics.manifest import CollectorRecord, RunManifest + +_GOLDEN = Path(__file__).parent / "golden" + + +class _Logger(EventLogger): + def event(self, level: str, message: str, **fields: int | float | str) -> None: + return None + + +class _CaptureWriter(ArtifactWriter): + def __init__(self) -> None: + self.contents = b"" + + def register_file(self, source: Path, *, media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), **kwargs: object) -> Artifact: + self.contents = source.read_bytes() + return Artifact( + id="artifact." + "0" * 32, + relative_path=f"core_test/{source.name}", + sha256="0" * 64, + size_bytes=len(self.contents), + media_type=media_type, + collector_id="core.test", + source_category="test", + collected_at="2026-01-02T03:04:05+00:00", + transformations=transformations, + evidence_kind=EvidenceKind.DERIVED, + name=source.name, + ) + + +def _context(root: Path, collector_id: str, writer: _CaptureWriter) -> CollectorContext: + return CollectorContext( + run_id="run-" + "0" * 32, + collector_id=collector_id, + workspace=root, + temporary_directory=root / "tmp", + artifacts=writer, + logger=_Logger(), + settings={}, + cancellation_file=root / ".cancelled", + ) + + +class GoldenOutputTests(unittest.TestCase): + def test_text_output_matches_golden_file(self) -> None: + expected = (_GOLDEN / "system_details.txt").read_text(encoding="utf-8") + with tempfile.TemporaryDirectory() as temporary: + writer = _CaptureWriter() + with patch.object(system_details.subprocess, "run", + return_value=CompletedProcess([], 0, expected, "")): + system_details.SystemDetailsCollector().collect( + _context(Path(temporary), "core.system.system_details", writer) + ) + self.assertEqual(expected.encode(), writer.contents) + + def test_csv_output_matches_golden_file(self) -> None: + expected = (_GOLDEN / "running_processes.csv").read_text(encoding="utf-8") + with tempfile.TemporaryDirectory() as temporary: + writer = _CaptureWriter() + with patch.object(running_processes.subprocess, "run", + return_value=CompletedProcess([], 0, expected, "")): + running_processes.RunningProcessesCollector().collect( + _context(Path(temporary), "core.process.running_processes", writer) + ) + self.assertEqual(expected.encode(), writer.contents) + + def test_html_output_matches_golden_file(self) -> None: + rendered = _render_bios_table({ + "Manufacturer": "Example & Sons", + "Name": "Golden ", + "SMBIOSBIOSVersion": "1.2.3", + "Version": "EXAMPLE - 1", + "ReleaseDate": "2026-01-02", + }) + self.assertEqual((_GOLDEN / "bios_info.html").read_text(encoding="utf-8"), rendered) + + def test_graph_output_matches_golden_file(self) -> None: + rendered = _render_connection_graph( + "UDP [::1]:5353 [::1]:5354 999\n" + "TCP 10.0.0.5:50000 1.1.1.1:443 ESTABLISHED 4242\n" + "TCP 10.0.0.5:50000 1.1.1.1:443 ESTABLISHED 4242\n" + ) + self.assertEqual((_GOLDEN / "connection_graph.dot").read_text(encoding="utf-8"), rendered) + + def test_normalized_manifest_matches_golden_shape(self) -> None: + manifest = RunManifest( + run_id="run-" + "0" * 32, + requested_at="", + status=RunStatus.SUCCEEDED, + request={"profile": "minimal"}, + configuration={"schema_version": 4}, + collectors=[CollectorRecord( + id="core.system.system_info", source="", status="succeeded", + started_at="", finished_at="", summary="collected", + )], + host={"platform": "win32", "hostname": "", "python": "", + "user": "", "is_administrator": "false"}, + resolved_plan=("core.system.system_info",), + plan_fingerprint="0" * 64, + finished_at="", + ) + normalized = json.loads(json.dumps(manifest.to_dict(), sort_keys=True)) + expected = json.loads((_GOLDEN / "manifest.json").read_text(encoding="utf-8")) + self.assertEqual(expected, normalized) + + +if __name__ == "__main__": + unittest.main() From 5a19b564af7e1747ced95bea5f83f613a85c0bfb Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 10:01:59 +0300 Subject: [PATCH 193/316] test: audit every collector lifecycle contract Exercise all 65 shipped collectors through cancellation-safe validation, preparation, collection, finalization, and cleanup while asserting typed terminal results and zero artifact publication on cancellation. Statically verify every artifact MIME declaration against collector metadata and fix system-info collection so cancellation is honored before evidence creation. --- TODO.md | 2 +- core/system/system_info.py | 4 +- tests/test_shipped_collectors.py | 90 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 9bbefe62..a949b7fa 100644 --- a/TODO.md +++ b/TODO.md @@ -510,7 +510,7 @@ Windows platform allows. run status, and reading artifacts. - [x] Keep platform adapters mockable so collectors can be tested on non-Windows development machines. -- [ ] Add contract tests for every collector's metadata, lifecycle, result states, +- [x] Add contract tests for every collector's metadata, lifecycle, result states, artifact declarations, and error behavior. - [x] Add planner tests for profiles, explicit selections, dependencies, conflicts, duplicate collectors, and invalid configurations. diff --git a/core/system/system_info.py b/core/system/system_info.py index fe75c24a..406b8dc3 100644 --- a/core/system/system_info.py +++ b/core/system/system_info.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics.contracts import CollectorContext +from logicytics.contracts import CollectorContext, CollectorStatus class SystemInfoCollector(CoreCollector): @@ -41,6 +41,8 @@ def validate(self, context: CollectorContext) -> ValidationResult: def collect(self, context: CollectorContext) -> CollectorResult: """Write and register the system inventory as a JSON evidence artifact.""" + if context.is_cancelled: + return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system inventory collection") context.report_progress("system_inventory_started") inventory = { "collected_at": datetime.now(timezone.utc).isoformat(), diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 7869514f..ac3b1c62 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -3,15 +3,43 @@ from __future__ import annotations import ast +import importlib.util +import tempfile import unittest from pathlib import Path from logicytics import ResourceClass +from logicytics.contracts import ( + ArtifactWriter, CollectorContext, CollectorStatus, EventLogger, EvidenceKind, +) from logicytics.discovery import preflight from logicytics.modes import EXECUTION_MODES, mode_matrix from logicytics.output_contracts import core_output_contract +class _RejectingWriter(ArtifactWriter): + """Prove cancellation paths never attempt to publish evidence.""" + + def register_file(self, source: Path, *, media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = ()): + raise AssertionError("a cancelled collector must not register an artifact") + + +class _NoopLogger(EventLogger): + def event(self, level: str, message: str, **fields: int | float | str) -> None: + return None + + +def _load_collector(path: Path, class_name: str): + module_name = f"contract_{path.parent.name}_{path.stem}" + spec = importlib.util.spec_from_file_location(module_name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, class_name)() + + class ShippedCollectorTests(unittest.TestCase): """Ensure every checked-in core collector passes strict discovery rules.""" @@ -192,6 +220,68 @@ def test_every_core_collector_has_a_stable_output_contract(self) -> None: )) self.assertEqual("run_retention_days", contract.retention) + def test_every_core_collector_obeys_the_typed_lifecycle_contract(self) -> None: + """All shipped collectors fail closed on cancellation without platform access or artifacts.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + for candidate in report.valid: + if candidate.kind.value != "core" or candidate.metadata is None: + continue + with self.subTest(collector=candidate.metadata.id), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + cancellation = root / ".cancelled" + cancellation.touch() + context = CollectorContext( + run_id="run-" + "0" * 32, + collector_id=candidate.metadata.id, + workspace=root, + temporary_directory=root / "tmp", + artifacts=_RejectingWriter(), + logger=_NoopLogger(), + settings={}, + cancellation_file=cancellation, + ) + collector = _load_collector(candidate.path, candidate.expected_class) + validation = collector.validate(context) + self.assertFalse(validation.valid) + self.assertTrue(validation.reasons) + prepared = collector.prepare(context) + self.assertTrue(hasattr(prepared, "valid")) + result = collector.collect(context) + self.assertIs(CollectorStatus.CANCELLED, result.status) + self.assertFalse(result.artifacts) + self.assertIs(result, collector.finalize(context, result)) + collector.cleanup(context) + + def test_every_registered_artifact_media_type_is_declared(self) -> None: + """Static artifact calls cannot introduce an undeclared output format.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + for candidate in report.valid: + if candidate.kind.value != "core" or candidate.metadata is None: + continue + tree = ast.parse(candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path)) + register_calls = [ + node for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "register_file" + ] + declared_at_calls = { + keyword.value.value + for node in register_calls for keyword in node.keywords + if keyword.arg == "media_type" + and isinstance(keyword.value, ast.Constant) + and isinstance(keyword.value.value, str) + } + if any(not any(keyword.arg == "media_type" for keyword in node.keywords) + for node in register_calls): + declared_at_calls.add("application/octet-stream") + with self.subTest(collector=candidate.metadata.id): + self.assertTrue(register_calls) + self.assertTrue(declared_at_calls) + self.assertLessEqual(declared_at_calls, set(candidate.metadata.output_media_types)) + if __name__ == "__main__": unittest.main() From 1953eb251485adee62592a1ace833f97d2aa87dd Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:05:02 +0300 Subject: [PATCH 194/316] docs: formalize the v4 compatibility boundary Document every supported legacy CLI, configuration, CODE evidence, and MOD bridge with its exact v4 replacement while making the run artifact tree the sole canonical output pipeline. Record removed internal shims, link contributor guidance, and add a regression audit that keeps documented aliases synchronized with the executable mode registry. --- CONTRIBUTING.md | 5 +++ MIGRATION.md | 56 ++++++++++++++++++++++++++++++++ README.md | 2 ++ TODO.md | 4 +-- tests/test_shipped_collectors.py | 19 ++++++++++- 5 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 MIGRATION.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9be04e7..6d567003 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,5 +1,10 @@ # Contributing to Logicytics +Before changing a legacy alias, configuration migration, CODE evidence import, +or MOD adapter, read [MIGRATION.md](MIGRATION.md). Compatibility bridges must +remain bounded translations into the canonical v4 request and artifact model; +do not add a second execution or output path. + Looking to contribute something to Logicytics? **Here's how you can help.** Please take a moment to review this document to make the contribution diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 00000000..5f7c0c4f --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,56 @@ +# Migrating to Logicytics v4 + +The v4 engine has one canonical data model: a typed request produces an isolated +run tree, registered artifacts, a manifest, and (unless disabled) a verified ZIP +with a SHA-256 sidecar. Compatibility code may translate an old input into that +model, but it may not create a second execution or output pipeline. + +## Supported compatibility bridges + +These bridges are intentionally supported for the v4 release and are not +internal shims: + +| Historical interface | v4 replacement | Retention policy | +| --- | --- | --- | +| `--default` | `--mode standard` | Supported v4 CLI alias | +| `--threaded` | `--mode balanced` | Supported v4 CLI alias | +| `--minimal` | `--mode quick` | Supported v4 CLI alias | +| `--depth` | `--mode thorough` | Supported v4 CLI alias | +| `--nopy` | `--mode non-python` | Supported v4 CLI alias | +| `--modded` | `--mode extensions` | Supported v4 CLI alias | +| `--performance-check` | `--mode performance` | Supported v4 CLI alias | +| schema-3 JSON settings | schema-4 `logicytics.json` | Read and migrated in memory | +| `CODE/config.ini` | schema-4 `logicytics.json` | Read-only fallback when JSON is absent | +| generated evidence in `CODE/` | `core.integration.legacy_code_outputs` | Deep-profile import only | +| `.py`, `.ps1`, `.bat`, `.exe` files in `MODS/` | typed plugin collector | Isolated compatibility adapter | + +Legacy inputs never enable plugins or MODS implicitly. Ambiguous settings, +duplicate keys, unsafe paths, undeclared capabilities, and unsupported fields +fail closed. Migration provenance is recorded in the run manifest. + +## Output migration + +There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/` compatibility +copies. Their v4 replacements are respectively the run tree under +`output/data/run-/`, `output/logs/`, `output/data/zip/`, and +`output/data/hashes/`. The run artifact tree and verified package layout in +[OUTPUTS.md](OUTPUTS.md) are canonical. + +The legacy CODE evidence collector preserves a historical file's relative name +only inside its collector-owned `legacy_code/` subtree. This is the sole legacy +output-name bridge because those names identify user-generated evidence. It +excludes source code, configuration, manifests, models, caches, virtual +environments, and library internals. No compatibility copy is written beside the +canonical artifact. + +## Removed internal shims + +The v4 public API replaces process-global run state, current-directory lookup, +direct collector `exit()` calls, shared cleanup, unregistered output scanning, +and direct packaging of `CODE/`. Collector code must use `CollectorContext`, +`ArtifactWriter`, typed results, and the supervisor lifecycle. These obsolete +internal behaviors are not exposed as aliases and must not be reintroduced. + +New integrations should implement the v4 collector contract directly. A bridge +is accepted only when it has a named owner, bounded input schema, explicit +replacement, isolation tests, and a documented removal or support policy. diff --git a/README.md b/README.md index 3b3285de..788c0c64 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,8 @@ remains an external sidecar because an archive cannot contain its own final dige Every shipped collector's stable filename or path pattern, MIME format, canonical package location, and retention behavior is defined in [OUTPUTS.md](OUTPUTS.md) and enforced by the isolated artifact writer. +Supported legacy inputs, their exact v4 replacements, and removed internal shims +are documented in [MIGRATION.md](MIGRATION.md). ## Requirements diff --git a/TODO.md b/TODO.md index a949b7fa..c866b635 100644 --- a/TODO.md +++ b/TODO.md @@ -530,12 +530,12 @@ Windows platform allows. - [x] Wrap legacy scripts in compatibility collectors before rewriting their data gathering logic. - [x] Move one collector at a time from direct filesystem writes to artifact APIs. -- [ ] Keep legacy output names as aliases or compatibility copies only where users +- [x] Keep legacy output names as aliases or compatibility copies only where users rely on them; make the run artifact tree canonical. - [x] Remove global mutable state after all collectors use `RunContext`. - [x] Remove direct `exit()` calls, current-directory assumptions, nested worker pools, and package-wide cleanup from collectors. -- [ ] Remove compatibility shims only after the v4.0 API and migration documentation +- [x] Remove compatibility shims only after the v4.0 API and migration documentation cover their replacement. ## v4.0 goals diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index ac3b1c62..21b2a54f 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -13,7 +13,7 @@ ArtifactWriter, CollectorContext, CollectorStatus, EventLogger, EvidenceKind, ) from logicytics.discovery import preflight -from logicytics.modes import EXECUTION_MODES, mode_matrix +from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.output_contracts import core_output_contract @@ -282,6 +282,23 @@ def test_every_registered_artifact_media_type_is_declared(self) -> None: self.assertTrue(declared_at_calls) self.assertLessEqual(declared_at_calls, set(candidate.metadata.output_media_types)) + def test_migration_documentation_covers_every_supported_bridge(self) -> None: + """Public compatibility stays explicit and canonical-output-only.""" + project_root = Path(__file__).resolve().parent.parent + migration = (project_root / "MIGRATION.md").read_text(encoding="utf-8") + command_flags = {"default_mode": "default", "performance_check": "performance-check"} + for flag in LEGACY_MODE_ALIASES: + with self.subTest(flag=flag): + self.assertIn(f"`--{command_flags.get(flag, flag.replace('_', '-'))}`", migration) + for bridge in ("CODE/config.ini", "core.integration.legacy_code_outputs", "MODS/"): + self.assertIn(bridge, migration) + self.assertIn("There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/`", migration) + contract = core_output_contract(next( + candidate.metadata for candidate in preflight(project_root).valid + if candidate.metadata and candidate.metadata.id == "core.integration.legacy_code_outputs" + )) + self.assertEqual(("legacy_code/**",), contract.workspace_patterns) + if __name__ == "__main__": unittest.main() From ee7d3cf14a7b36a4bc2c39bb86bcdf3293c03fc2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:09:11 +0300 Subject: [PATCH 195/316] refactor: centralize collector process execution Route every shipped collector command through one injectable process adapter that normalizes arguments, prohibits shell execution, validates timeouts, and delegates inside the existing worker capability guard. Preserve the familiar subprocess-style mock surface, allow only the contracts and platform service modules across the collector boundary, and prevent future direct subprocess imports with regression coverage. --- core/bluetooth/bluetooth_addresses.py | 2 +- core/bluetooth/bluetooth_history.py | 2 +- core/bluetooth/paired_devices.py | 2 +- core/diagnostics/sysinternals_report.py | 2 +- core/encryption/bitlocker_status.py | 2 +- core/encryption/bitlocker_volumes.py | 2 +- core/event_log/application_events.py | 2 +- core/event_log/security_events.py | 2 +- core/event_log/system_events.py | 2 +- core/hardware/battery_status.py | 2 +- core/hardware/display_adapters.py | 2 +- core/hardware/windows_features.py | 2 +- core/network/active_connections.py | 2 +- core/network/adapter_statistics.py | 2 +- core/network/arp_cache.py | 2 +- core/network/bandwidth_sample.py | 2 +- core/network/connection_processes.py | 2 +- core/network/dns_cache.py | 2 +- core/network/firewall_profiles.py | 2 +- core/network/network_adapters.py | 2 +- core/network/network_interfaces.py | 2 +- core/network/routing_table.py | 2 +- core/packet/connection_graph.py | 2 +- core/process/detailed_processes.py | 2 +- core/process/process_memory.py | 2 +- core/process/running_processes.py | 2 +- core/registry/hklm_backup.py | 2 +- core/storage/mounted_volumes.py | 2 +- core/storage/physical_disks.py | 2 +- core/system/bios_info.py | 2 +- core/system/computer_system.py | 2 +- core/system/defender_status.py | 2 +- core/system/environment_posture.py | 2 +- core/system/group_policy.py | 2 +- core/system/installed_drivers.py | 2 +- core/system/installed_updates.py | 2 +- core/system/local_accounts.py | 2 +- core/system/operating_system.py | 2 +- core/system/scheduled_tasks.py | 2 +- core/system/session_snapshot.py | 2 +- core/system/system_details.py | 2 +- core/system/system_diagnostics.py | 2 +- core/system/windows_services.py | 2 +- core/wireless/wifi_interfaces.py | 2 +- core/wireless/wifi_profile_keys.py | 2 +- core/wireless/wifi_profiles.py | 2 +- logicytics/command_runner.py | 7 ++-- logicytics/discovery.py | 5 +-- logicytics/platform_adapters.py | 26 ++++++++++++++ tests/test_platform_adapters.py | 45 +++++++++++++++++++++++++ 50 files changed, 125 insertions(+), 50 deletions(-) create mode 100644 logicytics/platform_adapters.py create mode 100644 tests/test_platform_adapters.py diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index 29a1be78..0b17ddf2 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -4,7 +4,7 @@ import json import re -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index 026d58ed..66d99a24 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone from shutil import which diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py index a5fd458a..ccd3bdcd 100644 --- a/core/bluetooth/paired_devices.py +++ b/core/bluetooth/paired_devices.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py index d21b97d8..c36e0a87 100644 --- a/core/diagnostics/sysinternals_report.py +++ b/core/diagnostics/sysinternals_report.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index 9275a57b..a9968af6 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 3f557dd6..f253e5a8 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -7,7 +7,7 @@ import json import platform import socket -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone from shutil import which diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index ebe27ba2..29642169 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index 6668fbcc..d5bc61ca 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py index 25b428f8..7be56981 100644 --- a/core/event_log/system_events.py +++ b/core/event_log/system_events.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index e7c3be93..d61ee34a 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py index efc7e53c..10ce307e 100644 --- a/core/hardware/display_adapters.py +++ b/core/hardware/display_adapters.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index e5320b9d..27a83d69 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/network/active_connections.py b/core/network/active_connections.py index e95e3df5..b14555ad 100644 --- a/core/network/active_connections.py +++ b/core/network/active_connections.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py index 8560660d..604998c6 100644 --- a/core/network/adapter_statistics.py +++ b/core/network/adapter_statistics.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/network/arp_cache.py b/core/network/arp_cache.py index bdeed453..3ce6ca88 100644 --- a/core/network/arp_cache.py +++ b/core/network/arp_cache.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index b06ac9d6..d5715070 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess import time from shutil import which diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py index cefbb80d..c30f07d5 100644 --- a/core/network/connection_processes.py +++ b/core/network/connection_processes.py @@ -4,7 +4,7 @@ import csv import io -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py index 846d96f8..3acb2da7 100644 --- a/core/network/dns_cache.py +++ b/core/network/dns_cache.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index b1416e28..7f0fed96 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py index 1a2c35e1..02bc05d0 100644 --- a/core/network/network_adapters.py +++ b/core/network/network_adapters.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index 8b648248..a6f10e71 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -4,7 +4,7 @@ import ipaddress import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/network/routing_table.py b/core/network/routing_table.py index 5fa9d00f..05d87dfa 100644 --- a/core/network/routing_table.py +++ b/core/network/routing_table.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 3894ff61..be0e760f 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py index 4dae7d31..2a719c7c 100644 --- a/core/process/detailed_processes.py +++ b/core/process/detailed_processes.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/process/process_memory.py b/core/process/process_memory.py index d91583ca..3ce7bfd9 100644 --- a/core/process/process_memory.py +++ b/core/process/process_memory.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/process/running_processes.py b/core/process/running_processes.py index eea7f7d3..b92b6656 100644 --- a/core/process/running_processes.py +++ b/core/process/running_processes.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index 341f5349..81a4375b 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index 2315f55f..a4c04bc5 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index a0c17236..7c1d0e1f 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/bios_info.py b/core/system/bios_info.py index f15b1772..9cb2741c 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -4,7 +4,7 @@ import html import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/system/computer_system.py b/core/system/computer_system.py index 94dc4321..8aa86a85 100644 --- a/core/system/computer_system.py +++ b/core/system/computer_system.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/system/defender_status.py b/core/system/defender_status.py index 173a4ab7..810d4db1 100644 --- a/core/system/defender_status.py +++ b/core/system/defender_status.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index bb157e25..7a6358ca 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/group_policy.py b/core/system/group_policy.py index 8a4afdf2..614cad8b 100644 --- a/core/system/group_policy.py +++ b/core/system/group_policy.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index 74bc7794..6772b4ea 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index d7dcdea7..5aeb5027 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/local_accounts.py b/core/system/local_accounts.py index bb74db55..8301ba30 100644 --- a/core/system/local_accounts.py +++ b/core/system/local_accounts.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/operating_system.py b/core/system/operating_system.py index fef0e330..2e7b16a8 100644 --- a/core/system/operating_system.py +++ b/core/system/operating_system.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/system/scheduled_tasks.py b/core/system/scheduled_tasks.py index 22ba73ba..07923586 100644 --- a/core/system/scheduled_tasks.py +++ b/core/system/scheduled_tasks.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py index d36b4e63..97deb39a 100644 --- a/core/system/session_snapshot.py +++ b/core/system/session_snapshot.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/system_details.py b/core/system/system_details.py index a6b90181..755295d2 100644 --- a/core/system/system_details.py +++ b/core/system/system_details.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import ( diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index a457b206..233b0ee8 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/system/windows_services.py b/core/system/windows_services.py index 60e6b371..ad766fc2 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py index ed0862a6..e0c8e831 100644 --- a/core/wireless/wifi_interfaces.py +++ b/core/wireless/wifi_interfaces.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index 2569c4d6..969645d7 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from pathlib import Path from shutil import which diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py index abdde3ea..af04ac84 100644 --- a/core/wireless/wifi_profiles.py +++ b/core/wireless/wifi_profiles.py @@ -2,7 +2,7 @@ from __future__ import annotations -import subprocess +from logicytics.platform_adapters import process_adapter as subprocess from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult diff --git a/logicytics/command_runner.py b/logicytics/command_runner.py index 27623603..a94287f3 100644 --- a/logicytics/command_runner.py +++ b/logicytics/command_runner.py @@ -2,10 +2,11 @@ from __future__ import annotations -import subprocess from dataclasses import dataclass from typing import Iterable +from logicytics.platform_adapters import process_adapter + @dataclass(frozen=True, slots=True) class CommandResult: @@ -24,7 +25,9 @@ def run_command(command: Iterable[str], *, timeout_seconds: float = 30) -> Comma raise ValueError("command must contain at least one argument") if timeout_seconds <= 0: raise ValueError("timeout_seconds must be positive") - completed = subprocess.run(normalized, capture_output=True, check=False, text=True, timeout=timeout_seconds) + completed = process_adapter.run( + normalized, capture_output=True, check=False, text=True, timeout=timeout_seconds + ) return CommandResult(normalized, completed.returncode, completed.stdout, completed.stderr) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index b9d56841..5f28b52b 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -25,6 +25,7 @@ "run_collection", "validation_worker", } _CACHE_SCHEMA_VERSION = 2 +_COLLECTOR_SERVICE_MODULES = {"logicytics.contracts", "logicytics.platform_adapters"} @dataclass(frozen=True, slots=True) @@ -294,7 +295,7 @@ def _validate_engine_boundary_imports(tree: ast.Module, candidate: CollectorCand for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - if alias.name.startswith("logicytics.") and alias.name != "logicytics.contracts": + if alias.name.startswith("logicytics.") and alias.name not in _COLLECTOR_SERVICE_MODULES: candidate.static_errors.append( f"forbidden application import: {alias.name} (line {node.lineno})" ) @@ -302,7 +303,7 @@ def _validate_engine_boundary_imports(tree: ast.Module, candidate: CollectorCand application_aliases.add(alias.asname or "logicytics") elif isinstance(node, ast.ImportFrom): module = node.module or "" - if module.startswith("logicytics.") and module != "logicytics.contracts": + if module.startswith("logicytics.") and module not in _COLLECTOR_SERVICE_MODULES: candidate.static_errors.append( f"forbidden application import: {module} (line {node.lineno})" ) diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py new file mode 100644 index 00000000..a3cb7c5f --- /dev/null +++ b/logicytics/platform_adapters.py @@ -0,0 +1,26 @@ +"""Injectable, mockable boundaries for host-specific operations used by collectors.""" + +from __future__ import annotations + +import subprocess +from collections.abc import Sequence +from typing import Any + + +class ProcessAdapter: + """Run one explicit, shell-free host command through a central policy seam.""" + + def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedProcess[str]: + """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" + normalized = tuple(str(argument) for argument in command) + if not normalized: + raise ValueError("command must contain at least one argument") + if options.get("shell") is True: + raise ValueError("collector process adapters never permit shell execution") + timeout = options.get("timeout") + if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0): + raise ValueError("command timeout must be positive") + return subprocess.run(normalized, **options) + + +process_adapter = ProcessAdapter() diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py new file mode 100644 index 00000000..2d362bfa --- /dev/null +++ b/tests/test_platform_adapters.py @@ -0,0 +1,45 @@ +"""Contract checks for centralized, injectable host platform seams.""" + +from __future__ import annotations + +import subprocess +import unittest +from pathlib import Path +from unittest.mock import patch + +from logicytics.platform_adapters import ProcessAdapter + + +class ProcessAdapterTests(unittest.TestCase): + def test_process_adapter_normalizes_and_delegates_shell_free_commands(self) -> None: + completed = subprocess.CompletedProcess(("tool", "argument"), 0, "output", "") + with patch("logicytics.platform_adapters.subprocess.run", return_value=completed) as invoke: + result = ProcessAdapter().run(["tool", Path("argument")], capture_output=True, + check=False, text=True, timeout=5) + self.assertIs(completed, result) + invoke.assert_called_once_with(("tool", "argument"), capture_output=True, + check=False, text=True, timeout=5) + + def test_process_adapter_rejects_shell_empty_and_unbounded_timeout_inputs(self) -> None: + adapter = ProcessAdapter() + for command, options, message in ( + ([], {}, "at least one"), + (["tool"], {"shell": True}, "shell"), + (["tool"], {"timeout": 0}, "timeout"), + ): + with self.subTest(command=command, options=options): + with self.assertRaisesRegex(ValueError, message): + adapter.run(command, **options) + + def test_core_collectors_cannot_import_the_process_module_directly(self) -> None: + project_root = Path(__file__).resolve().parent.parent + offenders = [ + str(path.relative_to(project_root)) + for path in (project_root / "core").rglob("*.py") + if "import subprocess" in path.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual([], offenders) + + +if __name__ == "__main__": + unittest.main() From eaeeaa15d0592e87ccdd2956b1e83eaa0297b5a9 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:12:28 +0300 Subject: [PATCH 196/316] refactor: centralize collector registry access Move installed-application, startup-entry, and USB storage registry reads behind one injectable adapter, including the Win32 last-write timestamp query. Keep the adapter import-safe outside Windows, expose read-only operations only, and prohibit future direct winreg imports in core collectors through regression coverage. --- core/registry/installed_applications.py | 2 +- core/registry/startup_applications.py | 2 +- core/usb/usb_storage_inventory.py | 15 ++----- logicytics/platform_adapters.py | 53 +++++++++++++++++++++++++ tests/test_platform_adapters.py | 26 +++++++++++- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py index 0199984d..8e54cc03 100644 --- a/core/registry/installed_applications.py +++ b/core/registry/installed_applications.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -import winreg from datetime import datetime, timezone from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import registry_adapter as winreg _UNINSTALL_PATHS = ( r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py index e9fd432a..0d5a5e78 100644 --- a/core/registry/startup_applications.py +++ b/core/registry/startup_applications.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -import winreg from datetime import datetime, timezone from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import registry_adapter as winreg _RUN_PATHS = ( ("HKEY_CURRENT_USER", winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run"), diff --git a/core/usb/usb_storage_inventory.py b/core/usb/usb_storage_inventory.py index a57c5b33..b2fc1dd1 100644 --- a/core/usb/usb_storage_inventory.py +++ b/core/usb/usb_storage_inventory.py @@ -2,11 +2,8 @@ from __future__ import annotations -import ctypes import json -import winreg -from ctypes import wintypes -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from logicytics import ( Capability, @@ -17,20 +14,14 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import registry_adapter as winreg _USBSTOR_PATH = r"SYSTEM\CurrentControlSet\Enum\USBSTOR" def _registry_last_write(key: winreg.HKEYType) -> str | None: """Return one registry key's last-write timestamp in UTC when Windows reports it.""" - timestamp = wintypes.FILETIME() - result = ctypes.windll.advapi32.RegQueryInfoKeyW( - wintypes.HKEY(key.handle), None, None, None, None, None, None, None, None, None, None, ctypes.byref(timestamp) - ) - if result != 0: - return None - ticks = (timestamp.dwHighDateTime << 32) | timestamp.dwLowDateTime - return (datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(microseconds=ticks // 10)).isoformat() + return winreg.last_write_time(key) def _enumerate_usb_storage() -> list[dict[str, str | None]]: diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index a3cb7c5f..ebd5712a 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -4,8 +4,14 @@ import subprocess from collections.abc import Sequence +from datetime import datetime, timedelta, timezone from typing import Any +try: + import winreg as _winreg +except ImportError: # pragma: no cover - exercised by non-Windows package imports + _winreg = None + class ProcessAdapter: """Run one explicit, shell-free host command through a central policy seam.""" @@ -24,3 +30,50 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro process_adapter = ProcessAdapter() + + +class RegistryAdapter: + """Expose bounded read-only registry primitives behind one mockable boundary.""" + + HKEYType = _winreg.HKEYType if _winreg is not None else object + HKEY_CURRENT_USER = _winreg.HKEY_CURRENT_USER if _winreg is not None else 0 + HKEY_LOCAL_MACHINE = _winreg.HKEY_LOCAL_MACHINE if _winreg is not None else 0 + + @staticmethod + def _module(): + if _winreg is None: + raise OSError("the Windows registry is unavailable on this platform") + return _winreg + + def OpenKey(self, key: Any, sub_key: str): + return self._module().OpenKey(key, sub_key) + + def QueryValueEx(self, key: Any, value_name: str): + return self._module().QueryValueEx(key, value_name) + + def EnumKey(self, key: Any, index: int) -> str: + return self._module().EnumKey(key, index) + + def EnumValue(self, key: Any, index: int): + return self._module().EnumValue(key, index) + + def last_write_time(self, key: Any) -> str | None: + """Return one key's Win32 last-write timestamp in UTC when available.""" + if _winreg is None: + return None + import ctypes + from ctypes import wintypes + + timestamp = wintypes.FILETIME() + result = ctypes.windll.advapi32.RegQueryInfoKeyW( + wintypes.HKEY(key.handle), None, None, None, None, None, None, + None, None, None, None, ctypes.byref(timestamp), + ) + if result != 0: + return None + ticks = (timestamp.dwHighDateTime << 32) | timestamp.dwLowDateTime + return (datetime(1601, 1, 1, tzinfo=timezone.utc) + + timedelta(microseconds=ticks // 10)).isoformat() + + +registry_adapter = RegistryAdapter() diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 2d362bfa..7db0b873 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -5,9 +5,9 @@ import subprocess import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import Mock, patch -from logicytics.platform_adapters import ProcessAdapter +from logicytics.platform_adapters import ProcessAdapter, RegistryAdapter class ProcessAdapterTests(unittest.TestCase): @@ -40,6 +40,28 @@ def test_core_collectors_cannot_import_the_process_module_directly(self) -> None ] self.assertEqual([], offenders) + def test_registry_adapter_delegates_only_read_operations(self) -> None: + adapter = RegistryAdapter() + fake = Mock() + fake.OpenKey.return_value = "key" + fake.QueryValueEx.return_value = ("value", 1) + fake.EnumKey.return_value = "child" + fake.EnumValue.return_value = ("name", "value", 1) + with patch("logicytics.platform_adapters._winreg", fake): + self.assertEqual("key", adapter.OpenKey(1, "Software")) + self.assertEqual(("value", 1), adapter.QueryValueEx("key", "Name")) + self.assertEqual("child", adapter.EnumKey("key", 0)) + self.assertEqual(("name", "value", 1), adapter.EnumValue("key", 0)) + + def test_core_collectors_cannot_import_winreg_directly(self) -> None: + project_root = Path(__file__).resolve().parent.parent + offenders = [ + str(path.relative_to(project_root)) + for path in (project_root / "core").rglob("*.py") + if "import winreg" in path.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual([], offenders) + if __name__ == "__main__": unittest.main() From 2528bee328e4fa7dcbf804c4061e917184a9e58e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:15:08 +0300 Subject: [PATCH 197/316] refactor: centralize collector network access Route host identity, resolver lookups, packed-address conversion, and raw packet socket creation through one injectable network adapter shared by the relevant core collectors. Preserve Windows socket constants and error behavior while prohibiting future direct socket imports with adapter delegation and repository audit tests. --- core/encryption/bitlocker_volumes.py | 2 +- core/network/network_identity.py | 2 +- core/packet/packet_capture.py | 2 +- core/system/system_info.py | 2 +- logicytics/platform_adapters.py | 39 ++++++++++++++++++++++++++++ tests/test_platform_adapters.py | 26 ++++++++++++++++++- 6 files changed, 68 insertions(+), 5 deletions(-) diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index f253e5a8..d6ec7967 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -6,13 +6,13 @@ import getpass import json import platform -import socket from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone from shutil import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import network_adapter as socket def _is_access_denied(detail: str) -> bool: diff --git a/core/network/network_identity.py b/core/network/network_identity.py index 24376556..0d44539a 100644 --- a/core/network/network_identity.py +++ b/core/network/network_identity.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import socket from datetime import datetime, timezone from logicytics import ( @@ -16,6 +15,7 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import network_adapter as socket class NetworkIdentityCollector(CoreCollector): diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index a2a84bdf..c9f9769c 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -3,7 +3,6 @@ from __future__ import annotations import csv -import socket import struct import time @@ -20,6 +19,7 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import network_adapter as socket def _packet_row(payload: bytes) -> dict[str, str] | None: diff --git a/core/system/system_info.py b/core/system/system_info.py index 406b8dc3..d16da410 100644 --- a/core/system/system_info.py +++ b/core/system/system_info.py @@ -6,11 +6,11 @@ import json import os import platform -import socket from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import network_adapter as socket class SystemInfoCollector(CoreCollector): diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index ebd5712a..8821bc55 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +import socket as _socket from collections.abc import Sequence from datetime import datetime, timedelta, timezone from typing import Any @@ -77,3 +78,41 @@ def last_write_time(self, key: Any) -> str | None: registry_adapter = RegistryAdapter() + + +class NetworkAdapter: + """Centralize local host resolution and explicitly authorized socket creation.""" + + AF_INET = _socket.AF_INET + SOCK_RAW = _socket.SOCK_RAW + IPPROTO_IP = _socket.IPPROTO_IP + IP_HDRINCL = _socket.IP_HDRINCL + SIO_RCVALL = getattr(_socket, "SIO_RCVALL", 0) + RCVALL_ON = getattr(_socket, "RCVALL_ON", 1) + RCVALL_OFF = getattr(_socket, "RCVALL_OFF", 0) + gaierror = _socket.gaierror + + @staticmethod + def gethostname() -> str: + return _socket.gethostname() + + @staticmethod + def gethostbyname(hostname: str) -> str: + return _socket.gethostbyname(hostname) + + @staticmethod + def getaddrinfo(host: str, port: int | str | None): + return _socket.getaddrinfo(host, port) + + @staticmethod + def inet_ntoa(packed_ip: bytes) -> str: + return _socket.inet_ntoa(packed_ip) + + @staticmethod + def socket(family: int = -1, type: int = -1, proto: int = -1, fileno: int | None = None): + if fileno is None: + return _socket.socket(family, type, proto) + return _socket.socket(family, type, proto, fileno=fileno) + + +network_adapter = NetworkAdapter() diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 7db0b873..3ed5ef3a 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -7,7 +7,7 @@ from pathlib import Path from unittest.mock import Mock, patch -from logicytics.platform_adapters import ProcessAdapter, RegistryAdapter +from logicytics.platform_adapters import NetworkAdapter, ProcessAdapter, RegistryAdapter class ProcessAdapterTests(unittest.TestCase): @@ -62,6 +62,30 @@ def test_core_collectors_cannot_import_winreg_directly(self) -> None: ] self.assertEqual([], offenders) + def test_network_adapter_delegates_host_and_socket_operations(self) -> None: + adapter = NetworkAdapter() + fake_socket = Mock() + fake_socket.gethostname.return_value = "host" + fake_socket.gethostbyname.return_value = "192.0.2.1" + fake_socket.getaddrinfo.return_value = [(2, 1, 6, "", ("192.0.2.1", 0))] + fake_socket.inet_ntoa.return_value = "198.51.100.1" + fake_socket.socket.return_value = "socket" + with patch("logicytics.platform_adapters._socket", fake_socket): + self.assertEqual("host", adapter.gethostname()) + self.assertEqual("192.0.2.1", adapter.gethostbyname("host")) + self.assertTrue(adapter.getaddrinfo("host", None)) + self.assertEqual("198.51.100.1", adapter.inet_ntoa(b"\x00" * 4)) + self.assertEqual("socket", adapter.socket(2, 3, 4)) + + def test_core_collectors_cannot_import_socket_directly(self) -> None: + project_root = Path(__file__).resolve().parent.parent + offenders = [ + str(path.relative_to(project_root)) + for path in (project_root / "core").rglob("*.py") + if "import socket" in path.read_text(encoding="utf-8").splitlines() + ] + self.assertEqual([], offenders) + if __name__ == "__main__": unittest.main() From b2455fadd32c6feb02c66f306d8e8b8527236eff Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:18:55 +0300 Subject: [PATCH 198/316] refactor: centralize executable and Win32 access Route executable discovery for all command-backed collectors and Win32 memory-library loading through validated, injectable platform adapter seams. Reject malformed executable and library names, keep non-Windows imports safe, and add repository audits preventing direct shutil.which or ctypes.WinDLL use in core collectors. --- core/bluetooth/bluetooth_addresses.py | 2 +- core/bluetooth/bluetooth_history.py | 2 +- core/bluetooth/paired_devices.py | 2 +- core/encryption/bitlocker_status.py | 2 +- core/encryption/bitlocker_volumes.py | 2 +- core/event_log/application_events.py | 2 +- core/event_log/security_events.py | 2 +- core/event_log/system_events.py | 2 +- core/hardware/battery_status.py | 2 +- core/hardware/display_adapters.py | 2 +- core/hardware/windows_features.py | 2 +- core/network/active_connections.py | 2 +- core/network/adapter_statistics.py | 2 +- core/network/arp_cache.py | 2 +- core/network/bandwidth_sample.py | 2 +- core/network/connection_processes.py | 2 +- core/network/dns_cache.py | 2 +- core/network/firewall_profiles.py | 2 +- core/network/network_adapters.py | 2 +- core/network/network_interfaces.py | 2 +- core/network/routing_table.py | 2 +- core/packet/connection_graph.py | 2 +- core/process/detailed_processes.py | 2 +- core/process/memory_map.py | 5 ++-- core/process/process_memory.py | 2 +- core/process/running_processes.py | 2 +- core/registry/hklm_backup.py | 2 +- core/storage/mounted_volumes.py | 2 +- core/storage/physical_disks.py | 2 +- core/system/bios_info.py | 2 +- core/system/computer_system.py | 2 +- core/system/defender_status.py | 2 +- core/system/environment_posture.py | 2 +- core/system/group_policy.py | 2 +- core/system/installed_drivers.py | 2 +- core/system/installed_updates.py | 2 +- core/system/local_accounts.py | 2 +- core/system/operating_system.py | 2 +- core/system/scheduled_tasks.py | 2 +- core/system/session_snapshot.py | 2 +- core/system/system_details.py | 2 +- core/system/system_diagnostics.py | 2 +- core/system/windows_services.py | 2 +- core/wireless/wifi_interfaces.py | 2 +- core/wireless/wifi_profile_keys.py | 2 +- core/wireless/wifi_profiles.py | 2 +- logicytics/platform_adapters.py | 25 +++++++++++++++++ tests/test_platform_adapters.py | 39 ++++++++++++++++++++++++++- 48 files changed, 111 insertions(+), 48 deletions(-) diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index 0b17ddf2..42346db3 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -5,7 +5,7 @@ import json import re from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index 66d99a24..dcb8eeb5 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -5,7 +5,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py index ccd3bdcd..3c29a124 100644 --- a/core/bluetooth/paired_devices.py +++ b/core/bluetooth/paired_devices.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index a9968af6..1ebc78ed 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index d6ec7967..8c11514a 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -8,7 +8,7 @@ import platform from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 29642169..09ebfb0a 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index d5bc61ca..e611556f 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py index 7be56981..f51e1164 100644 --- a/core/event_log/system_events.py +++ b/core/event_log/system_events.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index d61ee34a..e388bcd6 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py index 10ce307e..17117282 100644 --- a/core/hardware/display_adapters.py +++ b/core/hardware/display_adapters.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index 27a83d69..c36ebb6d 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/network/active_connections.py b/core/network/active_connections.py index b14555ad..0742415a 100644 --- a/core/network/active_connections.py +++ b/core/network/active_connections.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py index 604998c6..62503bea 100644 --- a/core/network/adapter_statistics.py +++ b/core/network/adapter_statistics.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/arp_cache.py b/core/network/arp_cache.py index 3ce6ca88..4c23064f 100644 --- a/core/network/arp_cache.py +++ b/core/network/arp_cache.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index d5715070..74521383 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -5,7 +5,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess import time -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py index c30f07d5..73afbb7b 100644 --- a/core/network/connection_processes.py +++ b/core/network/connection_processes.py @@ -5,7 +5,7 @@ import csv import io from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py index 3acb2da7..a39aabad 100644 --- a/core/network/dns_cache.py +++ b/core/network/dns_cache.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index 7f0fed96..2f2ff7d8 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py index 02bc05d0..54963b86 100644 --- a/core/network/network_adapters.py +++ b/core/network/network_adapters.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index a6f10e71..4695191e 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -5,7 +5,7 @@ import ipaddress import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/network/routing_table.py b/core/network/routing_table.py index 05d87dfa..c2458ce7 100644 --- a/core/network/routing_table.py +++ b/core/network/routing_table.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index be0e760f..e4043268 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py index 2a719c7c..f49806fd 100644 --- a/core/process/detailed_processes.py +++ b/core/process/detailed_processes.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 8a42b282..d4f4d814 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -10,6 +10,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import windows_api_adapter class _MemoryBasicInformation(ctypes.Structure): @@ -76,8 +77,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: except ValueError: return CollectorResult(CollectorStatus.FAILED, "memory-map dump_directory must stay inside the collector workspace") - kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) - psapi = ctypes.WinDLL("psapi", use_last_error=True) + kernel32 = windows_api_adapter.load_library("kernel32") + psapi = windows_api_adapter.load_library("psapi") process = kernel32.GetCurrentProcess() counters = _ProcessMemoryCounters() psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), ctypes.sizeof(counters)) diff --git a/core/process/process_memory.py b/core/process/process_memory.py index 3ce7bfd9..1e81c3aa 100644 --- a/core/process/process_memory.py +++ b/core/process/process_memory.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/process/running_processes.py b/core/process/running_processes.py index b92b6656..df9e5210 100644 --- a/core/process/running_processes.py +++ b/core/process/running_processes.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index 81a4375b..2db33c52 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index a4c04bc5..9c494f85 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index 7c1d0e1f..96bfcf4e 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/bios_info.py b/core/system/bios_info.py index 9cb2741c..0250aa33 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -5,7 +5,7 @@ import html import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/system/computer_system.py b/core/system/computer_system.py index 8aa86a85..6064b3df 100644 --- a/core/system/computer_system.py +++ b/core/system/computer_system.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/system/defender_status.py b/core/system/defender_status.py index 810d4db1..00228556 100644 --- a/core/system/defender_status.py +++ b/core/system/defender_status.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index 7a6358ca..b854974b 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/group_policy.py b/core/system/group_policy.py index 614cad8b..7ed2cb31 100644 --- a/core/system/group_policy.py +++ b/core/system/group_policy.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index 6772b4ea..0c49c7ec 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index 5aeb5027..ebe2c983 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/local_accounts.py b/core/system/local_accounts.py index 8301ba30..c2900f06 100644 --- a/core/system/local_accounts.py +++ b/core/system/local_accounts.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/operating_system.py b/core/system/operating_system.py index 2e7b16a8..2bf25438 100644 --- a/core/system/operating_system.py +++ b/core/system/operating_system.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/system/scheduled_tasks.py b/core/system/scheduled_tasks.py index 07923586..b435c21e 100644 --- a/core/system/scheduled_tasks.py +++ b/core/system/scheduled_tasks.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py index 97deb39a..a137de71 100644 --- a/core/system/session_snapshot.py +++ b/core/system/session_snapshot.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/system_details.py b/core/system/system_details.py index 755295d2..94a5e01d 100644 --- a/core/system/system_details.py +++ b/core/system/system_details.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import ( Capability, diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index 233b0ee8..99f25bdd 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/system/windows_services.py b/core/system/windows_services.py index ad766fc2..c21b8cf4 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py index e0c8e831..400d587a 100644 --- a/core/wireless/wifi_interfaces.py +++ b/core/wireless/wifi_interfaces.py @@ -4,7 +4,7 @@ import json from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index 969645d7..e4b47c71 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -4,7 +4,7 @@ from logicytics.platform_adapters import process_adapter as subprocess from pathlib import Path -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py index af04ac84..6926a613 100644 --- a/core/wireless/wifi_profiles.py +++ b/core/wireless/wifi_profiles.py @@ -3,7 +3,7 @@ from __future__ import annotations from logicytics.platform_adapters import process_adapter as subprocess -from shutil import which +from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 8821bc55..acd4ab63 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -4,6 +4,8 @@ import subprocess import socket as _socket +import ctypes +import shutil from collections.abc import Sequence from datetime import datetime, timedelta, timezone from typing import Any @@ -33,6 +35,13 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro process_adapter = ProcessAdapter() +def which(command: str) -> str | None: + """Resolve one host executable through the shared platform boundary.""" + if not isinstance(command, str) or not command.strip() or any(value in command for value in "\r\n\x00"): + raise ValueError("executable name must be a non-empty single-line string") + return shutil.which(command) + + class RegistryAdapter: """Expose bounded read-only registry primitives behind one mockable boundary.""" @@ -116,3 +125,19 @@ def socket(family: int = -1, type: int = -1, proto: int = -1, fileno: int | None network_adapter = NetworkAdapter() + + +class WindowsApiAdapter: + """Load explicitly named Win32 libraries behind a replaceable test seam.""" + + @staticmethod + def load_library(name: str): + if not isinstance(name, str) or not name or any(character in name for character in "/\\\x00"): + raise ValueError("Win32 library name must be a simple non-empty name") + loader = getattr(ctypes, "WinDLL", None) + if loader is None: + raise OSError("Win32 libraries are unavailable on this platform") + return loader(name, use_last_error=True) + + +windows_api_adapter = WindowsApiAdapter() diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 3ed5ef3a..71efcd0f 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -7,7 +7,9 @@ from pathlib import Path from unittest.mock import Mock, patch -from logicytics.platform_adapters import NetworkAdapter, ProcessAdapter, RegistryAdapter +from logicytics.platform_adapters import ( + NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, +) class ProcessAdapterTests(unittest.TestCase): @@ -86,6 +88,41 @@ def test_core_collectors_cannot_import_socket_directly(self) -> None: ] self.assertEqual([], offenders) + def test_windows_api_adapter_validates_names_and_uses_the_win32_loader(self) -> None: + loader = Mock(return_value="library") + with patch("logicytics.platform_adapters.ctypes.WinDLL", loader, create=True): + self.assertEqual("library", WindowsApiAdapter().load_library("kernel32")) + loader.assert_called_once_with("kernel32", use_last_error=True) + for invalid in ("", "../kernel32", "folder\\library"): + with self.subTest(name=invalid), self.assertRaises(ValueError): + WindowsApiAdapter().load_library(invalid) + + def test_core_collectors_cannot_load_win32_libraries_directly(self) -> None: + project_root = Path(__file__).resolve().parent.parent + offenders = [ + str(path.relative_to(project_root)) + for path in (project_root / "core").rglob("*.py") + if "ctypes.WinDLL(" in path.read_text(encoding="utf-8") + ] + self.assertEqual([], offenders) + + def test_executable_resolution_uses_the_platform_boundary(self) -> None: + with patch("logicytics.platform_adapters.shutil.which", return_value="C:/tool.exe") as resolve: + self.assertEqual("C:/tool.exe", which("tool")) + resolve.assert_called_once_with("tool") + for invalid in ("", "bad\nname", "bad\x00name"): + with self.subTest(command=invalid), self.assertRaises(ValueError): + which(invalid) + + def test_core_collectors_cannot_resolve_executables_through_shutil(self) -> None: + project_root = Path(__file__).resolve().parent.parent + offenders = [ + str(path.relative_to(project_root)) + for path in (project_root / "core").rglob("*.py") + if "from shutil import which" in path.read_text(encoding="utf-8") + ] + self.assertEqual([], offenders) + if __name__ == "__main__": unittest.main() From b060d4ed2f18e2163fecf2f8e288ad14c388c514 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:24:58 +0300 Subject: [PATCH 199/316] refactor: complete the collector platform layer Centralize host filesystem roots, environment paths, enumeration, walks, staging copies, and disk-space checks behind the shared injectable platform adapter while retaining ArtifactWriter as the only publication route. Document the complete process, PowerShell, WMI, registry, filesystem, network, Win32, and privilege boundaries; update cancellation mocks and prohibit direct host traversal primitives in core collectors. --- README.md | 8 ++++ TODO.md | 2 +- core/browser/browser_data_backup.py | 14 +++--- core/filesystem/sensitive_file_inventory.py | 9 ++-- core/filesystem/startup_folder_entries.py | 3 +- core/filesystem/system_drive_listing.py | 8 ++-- core/filesystem/system_drive_tree.py | 8 ++-- core/integration/legacy_code_outputs.py | 6 +-- core/media/media_backup.py | 9 ++-- core/process/memory_map.py | 5 +- core/ssh/ssh_backup.py | 5 +- core/system/windows_system_data_backup.py | 14 +++--- core/wireless/wifi_profile_keys.py | 4 +- logicytics/platform_adapters.py | 51 +++++++++++++++++++++ tests/test_collector_cancellation.py | 9 ++-- tests/test_platform_adapters.py | 30 +++++++++++- 16 files changed, 137 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 788c0c64..4fc2486b 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,14 @@ are documented in [MIGRATION.md](MIGRATION.md). The v4 core uses only the Python standard library. A collector may later declare its own dependency and capability requirements. +Collectors reach Windows through `logicytics.platform_adapters`: guarded process +execution covers PowerShell, WMI/CIM, WMIC, and command tools; dedicated adapters +cover registry reads, host filesystem discovery and staging, socket access, and +Win32 library loading. Privilege and UAC inspection remains in the application +environment boundary. Core collectors may use these mockable service seams and +`logicytics.contracts`, but cannot import orchestration services or bypass the +artifact writer for publication. + ## Commands Run these from the repository root: diff --git a/TODO.md b/TODO.md index c866b635..9ab7e308 100644 --- a/TODO.md +++ b/TODO.md @@ -408,7 +408,7 @@ Windows platform allows. coordinate collectors, and decide the final run status. - [x] **Collector layer:** perform one focused collection task through injected services; do not import the main application or depend on global state. -- [ ] **Platform layer:** centralize PowerShell, WMIC/WMI, registry, filesystem, +- [x] **Platform layer:** centralize PowerShell, WMIC/WMI, registry, filesystem, process, network, and privilege access behind testable adapters. - [x] **Artifact layer:** validate paths, stream output, normalize metadata, compute checksums, enforce size limits, and register artifacts. diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index d44bbc1a..651251e6 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -2,12 +2,11 @@ from __future__ import annotations -import os -import shutil from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter CHROMIUM_FILES = ("History", "History-journal", "Cookies", "Cookies-journal", "Login Data", "Login Data-journal", "Bookmarks", "Preferences") @@ -27,7 +26,7 @@ def _copy_file(source: Path, destination: Path, copied_bytes: int) -> tuple[Path if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES: return None, copied_bytes destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) + filesystem_adapter.copy_file(source, destination) return destination, copied_bytes + size @@ -58,8 +57,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: """Copy supported profile files from configured local browser locations.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before browser data backup") - local = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) - roaming = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + home = filesystem_adapter.home() + local = filesystem_adapter.environment_path("LOCALAPPDATA", home / "AppData" / "Local") + roaming = filesystem_adapter.environment_path("APPDATA", home / "AppData" / "Roaming") chromium_roots = (("chrome", local / "Google" / "Chrome" / "User Data"), ("edge", local / "Microsoft" / "Edge" / "User Data"), ("opera", roaming / "Opera Software" / "Opera Stable"), @@ -69,7 +69,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("browser_data_backup_started") for browser, root in chromium_roots: try: - profile_roots = [root] if browser.startswith("opera") else [path for path in root.iterdir() if + profile_roots = [root] if browser.startswith("opera") else [path for path in filesystem_adapter.children(root) if path.is_dir() and ( path.name == "Default" or path.name.startswith( "Profile "))] @@ -92,7 +92,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: copied.append(destination) firefox_profiles = roaming / "Mozilla" / "Firefox" / "Profiles" try: - profiles = [path for path in firefox_profiles.iterdir() if path.is_dir()] + profiles = [path for path in filesystem_adapter.children(firefox_profiles) if path.is_dir()] except OSError: profiles = [] for profile in profiles: diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index 6b0c5a31..70a5e2f1 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -2,12 +2,11 @@ from __future__ import annotations -import os -import shutil from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter KEYWORDS = ("password", "secret", "code", "login", "api", "key", "token", "auth", "credential", "private", "certificate", "ssh", "pgp", "wallet") @@ -20,7 +19,7 @@ def _copy(source: Path, destination: Path) -> Path | None: """Copy one regular source file into the private workspace, preserving metadata.""" try: destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) + filesystem_adapter.copy_file(source, destination) except OSError: return None return destination @@ -59,13 +58,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: """Find matches and copy them through the scheduler-owned collector worker.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before sensitive-file inventory") - root = Path(str(context.settings.get("root", os.environ.get("SystemDrive", "C:") + "\\"))) + root = Path(str(context.settings.get("root", filesystem_adapter.system_drive_root()))) max_directories = int(context.settings.get("max_directories", 5_000)) max_matches = int(context.settings.get("max_matches", 500)) matches: list[Path] = [] scanned_directories = 0 context.report_progress("sensitive_file_inventory_started", root=str(root)) - for directory, directories, filenames in os.walk(root, onerror=lambda _: None): + for directory, directories, filenames in filesystem_adapter.walk(root, onerror=lambda _: None): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file inventory") scanned_directories += 1 diff --git a/core/filesystem/startup_folder_entries.py b/core/filesystem/startup_folder_entries.py index fd39e0e0..f2c82819 100644 --- a/core/filesystem/startup_folder_entries.py +++ b/core/filesystem/startup_folder_entries.py @@ -8,6 +8,7 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter _MAXIMUM_ENTRIES = 500 @@ -66,7 +67,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: missing_folders += 1 continue try: - children = sorted(folder.iterdir(), key=lambda path: path.name.casefold()) + children = sorted(filesystem_adapter.children(folder), key=lambda path: path.name.casefold()) except OSError as error: return CollectorResult(CollectorStatus.SKIPPED, "Startup-folder access was denied", errors=(str(error),)) for child in children: diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py index 9bf90d91..0b23a552 100644 --- a/core/filesystem/system_drive_listing.py +++ b/core/filesystem/system_drive_listing.py @@ -2,12 +2,12 @@ from __future__ import annotations -import os from collections import deque from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter _DEFAULT_MAX_ENTRIES = 10_000 _DEFAULT_MAX_DEPTH = 16 @@ -26,7 +26,7 @@ def _scan_directory(directory: Path) -> tuple[list[str], list[Path]]: entries: list[str] = [] children: list[Path] = [] try: - with os.scandir(directory) as scan: + with filesystem_adapter.scan(directory) as scan: for entry in scan: try: is_directory = entry.is_dir(follow_symlinks=False) @@ -61,7 +61,7 @@ def validate(self, context: CollectorContext) -> ValidationResult: """Check cancellation state and system-drive availability before traversal.""" if context.is_cancelled: return ValidationResult(False, reasons=("run cancellation was requested",)) - root = Path(os.environ.get("SystemDrive", "C:") + "\\") + root = filesystem_adapter.system_drive_root() if not root.is_dir(): return ValidationResult(False, reasons=(f"system drive is unavailable: {root}",)) return ValidationResult(True) @@ -72,7 +72,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive listing collection") maximum_entries = _setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) maximum_depth = _setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) - root = Path(os.environ.get("SystemDrive", "C:") + "\\") + root = filesystem_adapter.system_drive_root() lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}", "# scheduling=run_supervisor"] pending = deque([(root, 0)]) diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py index 3fbae676..0ba74c3d 100644 --- a/core/filesystem/system_drive_tree.py +++ b/core/filesystem/system_drive_tree.py @@ -2,11 +2,11 @@ from __future__ import annotations -import os from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter _DEFAULT_MAX_ENTRIES = 5_000 _DEFAULT_MAX_DEPTH = 12 @@ -41,7 +41,7 @@ def validate(self, context: CollectorContext) -> ValidationResult: """Check cancellation state and system-drive availability before traversal.""" if context.is_cancelled: return ValidationResult(False, reasons=("run cancellation was requested",)) - system_drive = Path(os.environ.get("SystemDrive", "C:") + "\\") + system_drive = filesystem_adapter.system_drive_root() if not system_drive.is_dir(): return ValidationResult(False, reasons=(f"system drive is unavailable: {system_drive}",)) return ValidationResult(True) @@ -52,13 +52,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system-drive tree collection") maximum_entries = _bounded_setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) maximum_depth = _bounded_setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) - root = Path(os.environ.get("SystemDrive", "C:") + "\\") + root = filesystem_adapter.system_drive_root() lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}"] entries = 0 skipped = 0 truncated = False context.report_progress("system_drive_tree_started", max_entries=maximum_entries, max_depth=maximum_depth) - for current, directories, filenames in os.walk(root, topdown=True, followlinks=False, + for current, directories, filenames in filesystem_adapter.walk(root, topdown=True, followlinks=False, onerror=lambda _error: None): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive tree collection") diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py index 2f15b825..3c485fef 100644 --- a/core/integration/legacy_code_outputs.py +++ b/core/integration/legacy_code_outputs.py @@ -2,7 +2,6 @@ from __future__ import annotations -import shutil from pathlib import Path from logicytics import ( @@ -15,6 +14,7 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter _EVIDENCE_EXTENSIONS = { ".csv", ".dot", ".evtx", ".html", ".htm", ".json", ".log", ".reg", @@ -72,7 +72,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before legacy CODE import") code_root = self._code_root().resolve() candidates: list[Path] = [] - for path in sorted(code_root.rglob("*")): + for path in sorted(filesystem_adapter.recursive(code_root)): if context.is_cancelled: return CollectorResult.cancelled("cancelled during legacy CODE discovery") relative = path.relative_to(code_root) @@ -141,5 +141,5 @@ def collect(self, context: CollectorContext) -> CollectorResult: def cleanup(self, context: CollectorContext) -> None: """Remove only an unpublished temporary copy left by an interrupted import.""" - for temporary in context.workspace.rglob("*.tmp"): + for temporary in filesystem_adapter.recursive(context.workspace, "*.tmp"): temporary.unlink(missing_ok=True) diff --git a/core/media/media_backup.py b/core/media/media_backup.py index ffd93a99..f357e312 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -2,12 +2,12 @@ from __future__ import annotations -import shutil from datetime import datetime, timezone from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".mp4"} MAX_FILE_BYTES = 50 * 1024 * 1024 @@ -41,7 +41,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: """Copy supported regular media files while preserving source metadata.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before media backup") - source_roots = (("pictures", Path.home() / "Pictures"), ("videos", Path.home() / "Videos")) + home = filesystem_adapter.home() + source_roots = (("pictures", home / "Pictures"), ("videos", home / "Videos")) destination_root = context.workspace / "media_backup" copied: list[Path] = [] copied_bytes = 0 @@ -57,7 +58,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not exists: continue try: - candidates = source_root.rglob("*") + candidates = filesystem_adapter.recursive(source_root) for candidate in candidates: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during media backup") @@ -81,7 +82,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: destination = destination_root / category / f"{candidate.stem}_{timestamp}_{suffix}{candidate.suffix.casefold()}" suffix += 1 destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(candidate, destination) + filesystem_adapter.copy_file(candidate, destination) if context.is_cancelled: destination.unlink(missing_ok=True) for copied_path in copied: diff --git a/core/process/memory_map.py b/core/process/memory_map.py index d4f4d814..13b0fd7e 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -4,13 +4,12 @@ import ctypes import json -import shutil from ctypes import wintypes from pathlib import Path from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.platform_adapters import windows_api_adapter +from logicytics.platform_adapters import filesystem_adapter, windows_api_adapter class _MemoryBasicInformation(ctypes.Structure): @@ -115,7 +114,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: break regions.pop() truncated = True - if shutil.disk_usage(context.workspace).free < len(serialized.encode("utf-8")) + safety_margin: + if filesystem_adapter.disk_usage(context.workspace).free < len(serialized.encode("utf-8")) + safety_margin: return CollectorResult(CollectorStatus.SKIPPED, "insufficient free disk space after configured memory-map safety margin") if context.is_cancelled: diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index 9d6b88f6..feea4587 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -7,6 +7,7 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter MAX_FILE_BYTES = 10 * 1024 * 1024 MAX_ARCHIVE_SOURCE_BYTES = 128 * 1024 * 1024 @@ -38,7 +39,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: """Archive bounded regular files from .ssh while preserving relative paths.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before SSH backup") - ssh_directory = Path.home() / ".ssh" + ssh_directory = filesystem_adapter.home() / ".ssh" try: directory_exists = ssh_directory.is_dir() except OSError as error: @@ -54,7 +55,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: cancelled = False with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output: try: - candidates = sorted(ssh_directory.rglob("*")) + candidates = sorted(filesystem_adapter.recursive(ssh_directory)) except OSError as error: return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", errors=(str(error),)) diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index c1c0d72e..2be8ae46 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -2,12 +2,11 @@ from __future__ import annotations -import os -import shutil from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter MAX_FILE_BYTES = 100 * 1024 * 1024 @@ -39,8 +38,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: """Copy supported Windows system-data files and preserve source metadata.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Windows system-data backup") - windows = Path(os.environ.get("SystemRoot", r"C:\Windows")) - program_data = Path(os.environ.get("ProgramData", r"C:\ProgramData")) + windows = filesystem_adapter.environment_path("SystemRoot", Path(r"C:\Windows")) + program_data = filesystem_adapter.environment_path("ProgramData", Path(r"C:\ProgramData")) candidates = [ ("group_policy", windows / "System32" / "GroupPolicy" / "Machine" / "Registry.pol"), ("group_policy", windows / "System32" / "GroupPolicy" / "User" / "Registry.pol"), @@ -48,8 +47,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: ("event_logs", windows / "System32" / "winevt" / "Logs" / "Application.evtx"), ("event_logs", windows / "System32" / "winevt" / "Logs" / "Security.evtx"), ] - candidates.extend(("security_support", path) for path in - (program_data / "Microsoft" / "Windows Defender" / "Support").glob("*.log")) + candidates.extend(("security_support", path) for path in filesystem_adapter.glob( + program_data / "Microsoft" / "Windows Defender" / "Support", "*.log" + )) copied: list[Path] = [] context.report_progress("windows_system_data_backup_started") for label, source in candidates: @@ -60,7 +60,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: continue destination = context.workspace / "windows_system_data" / label / source.name destination.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source, destination) + filesystem_adapter.copy_file(source, destination) except OSError: continue if context.is_cancelled: diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index e4b47c71..b5feb45b 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -4,7 +4,7 @@ from logicytics.platform_adapters import process_adapter as subprocess from pathlib import Path -from logicytics.platform_adapters import which +from logicytics.platform_adapters import filesystem_adapter, which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus @@ -63,7 +63,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.SKIPPED, "Wi-Fi profile-key access was denied for the current account", errors=(message,)) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile-key export failed", errors=(message,)) - profiles = sorted(path for path in export_directory.glob("*.xml") if path.is_file()) + profiles = sorted(path for path in filesystem_adapter.glob(export_directory, "*.xml") if path.is_file()) if not profiles: return CollectorResult(CollectorStatus.SKIPPED, "no saved Wi-Fi profiles with key material were exported") artifacts = tuple( diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index acd4ab63..43b4eca7 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -6,6 +6,8 @@ import socket as _socket import ctypes import shutil +import os +from pathlib import Path from collections.abc import Sequence from datetime import datetime, timedelta, timezone from typing import Any @@ -141,3 +143,52 @@ def load_library(name: str): windows_api_adapter = WindowsApiAdapter() + + +class FilesystemAdapter: + """Centralize host discovery and evidence staging while leaving publication to ArtifactWriter.""" + + @staticmethod + def home() -> Path: + return Path.home() + + @staticmethod + def environment_path(name: str, fallback: Path) -> Path: + if not isinstance(name, str) or not name or not name.replace("_", "").isalnum(): + raise ValueError("environment path name must be an alphanumeric variable name") + return Path(os.environ.get(name, fallback)) + + @staticmethod + def system_drive_root() -> Path: + return Path(os.environ.get("SystemDrive", "C:") + "\\") + + @staticmethod + def children(path: Path): + return path.iterdir() + + @staticmethod + def glob(path: Path, pattern: str): + return path.glob(pattern) + + @staticmethod + def recursive(path: Path, pattern: str = "*"): + return path.rglob(pattern) + + @staticmethod + def walk(path: Path, **options: Any): + return os.walk(path, **options) + + @staticmethod + def scan(path: Path): + return os.scandir(path) + + @staticmethod + def copy_file(source: Path, destination: Path) -> None: + shutil.copy2(source, destination) + + @staticmethod + def disk_usage(path: Path): + return shutil.disk_usage(path) + + +filesystem_adapter = FilesystemAdapter() diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index 2966790d..82faf478 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -100,8 +100,8 @@ def cancel_media_copy(source: Path, destination: Path) -> None: destination.write_bytes(source.read_bytes()) media_context.cancelled = True - with patch.object(media_backup.Path, "home", return_value=home), patch.object( - media_backup.shutil, "copy2", side_effect=cancel_media_copy): + with patch.object(media_backup.filesystem_adapter, "home", return_value=home), patch.object( + media_backup.filesystem_adapter, "copy_file", side_effect=cancel_media_copy): media_result = media_backup.MediaBackupCollector().collect(media_context) self.assertEqual(CollectorStatus.CANCELLED, media_result.status) self.assertEqual([], [path for path in media_context.workspace.rglob("*") if path.is_file()]) @@ -145,7 +145,8 @@ def cancel_system_copy(source: Path, destination: Path) -> None: system_context.cancelled = True with patch.dict(os.environ, {"SystemRoot": str(windows), "ProgramData": str(root / "ProgramData")}), \ - patch.object(windows_system_data_backup.shutil, "copy2", side_effect=cancel_system_copy): + patch.object(windows_system_data_backup.filesystem_adapter, "copy_file", + side_effect=cancel_system_copy): system_result = windows_system_data_backup.WindowsSystemDataBackupCollector().collect(system_context) self.assertEqual(CollectorStatus.CANCELLED, system_result.status) self.assertEqual([], [path for path in system_context.workspace.rglob("*") if path.is_file()]) @@ -163,7 +164,7 @@ def cancel_archive_write(archive, *args, **kwargs): ssh_context.cancelled = True return result - with patch.object(ssh_backup.Path, "home", return_value=home), patch.object( + with patch.object(ssh_backup.filesystem_adapter, "home", return_value=home), patch.object( ssh_backup.zipfile.ZipFile, "write", new=cancel_archive_write): ssh_result = ssh_backup.SshBackupCollector().collect(ssh_context) self.assertEqual(CollectorStatus.CANCELLED, ssh_result.status) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 71efcd0f..6f071268 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -3,12 +3,13 @@ from __future__ import annotations import subprocess +import tempfile import unittest from pathlib import Path from unittest.mock import Mock, patch from logicytics.platform_adapters import ( - NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, + FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, ) @@ -123,6 +124,33 @@ def test_core_collectors_cannot_resolve_executables_through_shutil(self) -> None ] self.assertEqual([], offenders) + def test_filesystem_adapter_enumerates_and_copies_without_publishing(self) -> None: + adapter = FilesystemAdapter() + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source" + source.mkdir() + evidence = source / "evidence.txt" + evidence.write_text("evidence", encoding="utf-8") + destination = root / "staged.txt" + self.assertEqual([evidence], list(adapter.children(source))) + self.assertEqual([evidence], list(adapter.glob(source, "*.txt"))) + self.assertEqual([evidence], list(adapter.recursive(source))) + adapter.copy_file(evidence, destination) + self.assertEqual(b"evidence", destination.read_bytes()) + self.assertGreater(adapter.disk_usage(root).total, 0) + + def test_core_collectors_use_the_filesystem_boundary_for_host_traversal(self) -> None: + project_root = Path(__file__).resolve().parent.parent + forbidden = ("Path.home()", "os.walk(", "os.scandir(", "shutil.copy2(", "shutil.disk_usage(", + ".rglob(", ".iterdir(") + offenders = [] + for path in (project_root / "core").rglob("*.py"): + source = path.read_text(encoding="utf-8") + if any(token in source for token in forbidden): + offenders.append(str(path.relative_to(project_root))) + self.assertEqual([], offenders) + if __name__ == "__main__": unittest.main() From da9a1ffa00a2e1c9b33e13c1f31470ca70162a00 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:32:47 +0300 Subject: [PATCH 200/316] feat: bound and stream collector data paths Spool command stdout and stderr incrementally to temporary files with a hard 64 MiB per-stream ceiling, preventing unbounded child output from accumulating in process memory. Write packet observations directly to CSV as they arrive, centralize the remaining drive and privilege Win32 calls, and add functional coverage for capture limits and streamed packet rows. --- TODO.md | 2 +- core/encryption/bitlocker_volumes.py | 5 +- core/memory/memory_snapshot.py | 4 +- core/packet/packet_capture.py | 103 +++++++++++++++------------ core/storage/logical_drives.py | 8 ++- core/storage/volume_details.py | 3 +- logicytics/platform_adapters.py | 35 ++++++++- tests/test_platform_adapters.py | 74 +++++++++++++++++-- 8 files changed, 173 insertions(+), 61 deletions(-) diff --git a/TODO.md b/TODO.md index 9ab7e308..82dce563 100644 --- a/TODO.md +++ b/TODO.md @@ -430,7 +430,7 @@ Windows platform allows. memory collection, event-log queries, and copy loops. - [x] Prevent path traversal and symlink/junction escapes from leaving the run workspace or copying unintended locations. -- [ ] Avoid loading entire command output, files, memory maps, or packet sets into +- [x] Avoid loading entire command output, files, memory maps, or packet sets into memory when streaming is possible. - [x] Redact secrets from logs and from diagnostic metadata even when the artifact itself is intentionally collected. diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 8c11514a..1a0c903d 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -2,7 +2,6 @@ from __future__ import annotations -import ctypes import getpass import json import platform @@ -12,7 +11,7 @@ from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.platform_adapters import network_adapter as socket +from logicytics.platform_adapters import network_adapter as socket, windows_api_adapter def _is_access_denied(detail: str) -> bool: @@ -24,7 +23,7 @@ def _is_access_denied(detail: str) -> bool: def _is_administrator() -> bool | None: """Return the local administrator token state when Windows can report it.""" try: - return bool(ctypes.windll.shell32.IsUserAnAdmin()) + return windows_api_adapter.is_administrator() except OSError: return None diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index a2d599f1..3a145afe 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -8,6 +8,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import windows_api_adapter class _MemoryStatus(ctypes.Structure): @@ -30,7 +31,8 @@ def _read_memory_status() -> _MemoryStatus: """Read Windows aggregate memory counters or raise the platform error.""" status = _MemoryStatus() status.dwLength = ctypes.sizeof(_MemoryStatus) - if not ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): + kernel32 = windows_api_adapter.load_library("kernel32") + if not kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): raise ctypes.WinError() return status diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index c9f9769c..50405748 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -89,59 +89,72 @@ def collect(self, context: CollectorContext) -> CollectorResult: packet_count=count, retry_window_seconds=retry_window, ) - capture: socket.socket | None = None - observations: list[dict[str, str]] = [] - try: - capture = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP) - capture.bind((interface, 0)) - capture.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) - capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON) - deadline = time.monotonic() + timeout - retry_deadline = time.monotonic() + retry_window - while len(observations) < count and time.monotonic() < deadline: - if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during packet capture") - ready, _, _ = select.select([capture], [], [], min(1.0, deadline - time.monotonic())) - if not ready: - continue - try: - payload = capture.recv(65_535) - except OSError: - if time.monotonic() < retry_deadline: - time.sleep(0.1) - continue - raise - row = _packet_row(payload) - if row is not None: - observations.append(row) - except PermissionError as error: - return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture requires an elevated account", - errors=(str(error),)) - except OSError as error: - if error.winerror in {5, 10013}: - return CollectorResult(CollectorStatus.SKIPPED, "raw packet capture was denied for the current account", - errors=(str(error),)) - return CollectorResult(CollectorStatus.FAILED, "raw packet capture failed", errors=(str(error),)) - finally: - if capture is not None: - try: - capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF) - except OSError: - pass - capture.close() - if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before packet-capture serialization") output = context.workspace / "packet_capture.csv" + capture: socket.socket | None = None + observation_count = 0 + early_result: CollectorResult | None = None with output.open("w", newline="", encoding="utf-8") as stream: writer = csv.DictWriter(stream, fieldnames=("source_ip", "destination_ip", "protocol", "source_port", "destination_port", "packet_bytes")) writer.writeheader() - writer.writerows(observations) + try: + capture = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP) + capture.bind((interface, 0)) + capture.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) + capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON) + deadline = time.monotonic() + timeout + retry_deadline = time.monotonic() + retry_window + while observation_count < count and time.monotonic() < deadline: + if context.is_cancelled: + early_result = CollectorResult( + CollectorStatus.CANCELLED, "cancelled during packet capture" + ) + break + ready, _, _ = select.select([capture], [], [], min(1.0, deadline - time.monotonic())) + if not ready: + continue + try: + payload = capture.recv(65_535) + except OSError: + if time.monotonic() < retry_deadline: + time.sleep(0.1) + continue + raise + row = _packet_row(payload) + if row is not None: + writer.writerow(row) + observation_count += 1 + except PermissionError as error: + early_result = CollectorResult( + CollectorStatus.SKIPPED, "raw packet capture requires an elevated account", + errors=(str(error),), + ) + except OSError as error: + if error.winerror in {5, 10013}: + early_result = CollectorResult( + CollectorStatus.SKIPPED, + "raw packet capture was denied for the current account", + errors=(str(error),), + ) + else: + early_result = CollectorResult( + CollectorStatus.FAILED, "raw packet capture failed", errors=(str(error),) + ) + finally: + if capture is not None: + try: + capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF) + except OSError: + pass + capture.close() + if early_result is not None: + output.unlink(missing_ok=True) + return early_result if context.is_cancelled: output.unlink(missing_ok=True) - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during packet-capture serialization") + return CollectorResult(CollectorStatus.CANCELLED, "cancelled after packet capture") artifact = context.artifacts.register_file(output, media_type="text/csv") - context.report_progress("packet_capture_finished", observation_count=len(observations), + context.report_progress("packet_capture_finished", observation_count=observation_count, bytes_written=artifact.size_bytes) return CollectorResult.succeeded("packet metadata captured", (artifact,)) diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index 2e6864d2..385ee44b 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -8,6 +8,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import windows_api_adapter _DRIVE_TYPES = { 0: "unknown", @@ -22,7 +23,8 @@ def _logical_drives() -> list[dict[str, int | str]]: """Return Windows logical drive metadata without walking any filesystem contents.""" - mask = ctypes.windll.kernel32.GetLogicalDrives() + kernel32 = windows_api_adapter.load_library("kernel32") + mask = kernel32.GetLogicalDrives() if mask == 0: raise ctypes.WinError() drives: list[dict[str, int | str]] = [] @@ -33,14 +35,14 @@ def _logical_drives() -> list[dict[str, int | str]]: available = ctypes.c_ulonglong() total = ctypes.c_ulonglong() free = ctypes.c_ulonglong() - if not ctypes.windll.kernel32.GetDiskFreeSpaceExW( + if not kernel32.GetDiskFreeSpaceExW( root, ctypes.byref(available), ctypes.byref(total), ctypes.byref(free), ): continue - drive_type_code = ctypes.windll.kernel32.GetDriveTypeW(root) + drive_type_code = kernel32.GetDriveTypeW(root) drives.append( { "root": root, diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index 2742557d..fe5e3cbc 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -9,6 +9,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import windows_api_adapter _DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", 6: "ram_disk"} @@ -16,7 +17,7 @@ def _volume_details() -> list[dict[str, int | str]]: """Read mounted logical-volume capacity, label, and filesystem metadata only.""" - kernel32 = ctypes.windll.kernel32 + kernel32 = windows_api_adapter.load_library("kernel32") mask = kernel32.GetLogicalDrives() if mask == 0: raise ctypes.WinError() diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 43b4eca7..326a510e 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -7,6 +7,7 @@ import ctypes import shutil import os +import tempfile from pathlib import Path from collections.abc import Sequence from datetime import datetime, timedelta, timezone @@ -21,6 +22,8 @@ class ProcessAdapter: """Run one explicit, shell-free host command through a central policy seam.""" + maximum_capture_bytes = 64 * 1024 * 1024 + def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedProcess[str]: """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" normalized = tuple(str(argument) for argument in command) @@ -31,7 +34,31 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro timeout = options.get("timeout") if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0): raise ValueError("command timeout must be positive") - return subprocess.run(normalized, **options) + capture_output = options.pop("capture_output", False) + if not capture_output: + return subprocess.run(normalized, **options) + if "stdout" in options or "stderr" in options: + raise ValueError("capture_output cannot be combined with explicit streams") + wants_text = bool(options.pop("text", False) or options.get("encoding") is not None) + encoding = options.pop("encoding", None) or "utf-8" + errors = options.pop("errors", None) or "replace" + with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + completed = subprocess.run(normalized, stdout=stdout, stderr=stderr, **options) + stdout_size = stdout.tell() + stderr_size = stderr.tell() + if stdout_size > self.maximum_capture_bytes or stderr_size > self.maximum_capture_bytes: + raise ValueError( + f"command output exceeds the {self.maximum_capture_bytes}-byte capture limit" + ) + stdout.seek(0) + stderr.seek(0) + stdout_bytes = stdout.read() + stderr_bytes = stderr.read() + captured_stdout = stdout_bytes.decode(encoding, errors) if wants_text else stdout_bytes + captured_stderr = stderr_bytes.decode(encoding, errors) if wants_text else stderr_bytes + return subprocess.CompletedProcess( + normalized, completed.returncode, captured_stdout, captured_stderr + ) process_adapter = ProcessAdapter() @@ -141,6 +168,12 @@ def load_library(name: str): raise OSError("Win32 libraries are unavailable on this platform") return loader(name, use_last_error=True) + def is_administrator(self) -> bool | None: + try: + return bool(self.load_library("shell32").IsUserAnAdmin()) + except OSError: + return None + windows_api_adapter = WindowsApiAdapter() diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 6f071268..ade7cc36 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -8,6 +8,9 @@ from pathlib import Path from unittest.mock import Mock, patch +from core.packet import packet_capture +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import ( FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, ) @@ -15,13 +18,71 @@ class ProcessAdapterTests(unittest.TestCase): def test_process_adapter_normalizes_and_delegates_shell_free_commands(self) -> None: - completed = subprocess.CompletedProcess(("tool", "argument"), 0, "output", "") - with patch("logicytics.platform_adapters.subprocess.run", return_value=completed) as invoke: + def execute(command, *, stdout, stderr, **options): + stdout.write(b"output") + stderr.write(b"warning") + return subprocess.CompletedProcess(command, 7) + + with patch("logicytics.platform_adapters.subprocess.run", side_effect=execute) as invoke: result = ProcessAdapter().run(["tool", Path("argument")], capture_output=True, check=False, text=True, timeout=5) - self.assertIs(completed, result) - invoke.assert_called_once_with(("tool", "argument"), capture_output=True, - check=False, text=True, timeout=5) + self.assertEqual(("tool", "argument"), result.args) + self.assertEqual(7, result.returncode) + self.assertEqual("output", result.stdout) + self.assertEqual("warning", result.stderr) + self.assertEqual(("tool", "argument"), invoke.call_args.args[0]) + self.assertEqual({"check": False, "timeout": 5}, { + key: value for key, value in invoke.call_args.kwargs.items() + if key not in {"stdout", "stderr"} + }) + + def test_process_adapter_rejects_captured_streams_over_the_hard_limit(self) -> None: + adapter = ProcessAdapter() + adapter.maximum_capture_bytes = 3 + + def execute(command, *, stdout, stderr, **options): + stdout.write(b"four") + return subprocess.CompletedProcess(command, 0) + + with patch("logicytics.platform_adapters.subprocess.run", side_effect=execute): + with self.assertRaisesRegex(ValueError, "capture limit"): + adapter.run(["tool"], capture_output=True, text=True) + + def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: + packet = bytearray(24) + packet[0] = 0x45 + packet[9] = 6 + packet[12:16] = bytes((192, 0, 2, 1)) + packet[16:20] = bytes((198, 51, 100, 2)) + packet[20:24] = bytes((0x1F, 0x90, 0x01, 0xBB)) + capture = Mock() + capture.recv.side_effect = (bytes(packet), bytes(packet)) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifacts = root / "artifacts" + workspace.mkdir() + artifacts.mkdir() + context = CollectorContext( + run_id="run-" + "0" * 32, + collector_id="core.packet.packet_capture", + workspace=workspace, + temporary_directory=workspace / "tmp", + artifacts=WorkspaceArtifactWriter( + "core.packet.packet_capture", workspace, artifacts, 1024 * 1024, 1 + ), + logger=Mock(), + settings={"interface": "192.0.2.1", "packet_count": 2, + "timeout_seconds": 1, "retry_window_seconds": 0}, + cancellation_file=workspace / ".cancelled", + ) + with patch.object(packet_capture.socket, "socket", return_value=capture), \ + patch.object(packet_capture.select, "select", return_value=([capture], [], [])): + result = packet_capture.PacketCaptureCollector().collect(context) + rows = (workspace / "packet_capture.csv").read_text(encoding="utf-8").splitlines() + self.assertIs(CollectorStatus.SUCCEEDED, result.status) + self.assertEqual(3, len(rows)) + self.assertEqual(2, capture.recv.call_count) def test_process_adapter_rejects_shell_empty_and_unbounded_timeout_inputs(self) -> None: adapter = ProcessAdapter() @@ -103,7 +164,8 @@ def test_core_collectors_cannot_load_win32_libraries_directly(self) -> None: offenders = [ str(path.relative_to(project_root)) for path in (project_root / "core").rglob("*.py") - if "ctypes.WinDLL(" in path.read_text(encoding="utf-8") + if any(token in path.read_text(encoding="utf-8") + for token in ("ctypes.WinDLL(", "ctypes.windll.")) ] self.assertEqual([], offenders) From aed4871ec6a011bae42a9f35f7c2af96934546ac Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:33:59 +0300 Subject: [PATCH 201/316] test: enforce one policy contract per collector Require each shipped module to own exactly one core collector class and one immutable permission, privilege, network, sensitivity, timeout, cost, path, and format signature. Document when contributors must split a feature into a separate collector ID and prevent settings from hiding a second authorization or output contract. --- CONTRIBUTING.md | 6 ++++++ TODO.md | 2 +- tests/test_shipped_collectors.py | 29 +++++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d567003..f596bbce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,12 @@ or MOD adapter, read [MIGRATION.md](MIGRATION.md). Compatibility bridges must remain bounded translations into the canonical v4 request and artifact model; do not add a second execution or output path. +Each collector module owns exactly one collector class and one primary job. Split +a feature into a separate collector ID whenever it needs a different capability, +privilege level, network reach, sensitive-data category, timeout/cost profile, or +output name/format. Do not hide a second authorization or output contract behind +a setting in an existing collector. + Looking to contribute something to Logicytics? **Here's how you can help.** Please take a moment to review this document to make the contribution diff --git a/TODO.md b/TODO.md index 82dce563..4079bb99 100644 --- a/TODO.md +++ b/TODO.md @@ -259,7 +259,7 @@ that fails any mandatory rule is not a collector and must not be launched. documented responsibility. - [x] Reject a collector whose name, metadata, class, or outputs claim one specialty while its implementation declares or performs an unrelated primary job. -- [ ] Require a separate collector when a feature has a different permission model, +- [x] Require a separate collector when a feature has a different permission model, timeout profile, sensitive-data classification, or output contract. #### Required class interface diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 21b2a54f..e526ffc4 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -299,6 +299,35 @@ def test_migration_documentation_covers_every_supported_bridge(self) -> None: )) self.assertEqual(("legacy_code/**",), contract.workspace_patterns) + def test_each_core_module_owns_exactly_one_policy_contract(self) -> None: + """Different permission, sensitivity, timeout, or output policies require separate IDs.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + signatures: dict[str, tuple[object, ...]] = {} + for candidate in report.valid: + if candidate.kind.value != "core" or candidate.metadata is None: + continue + tree = ast.parse(candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path)) + collector_classes = [ + node for node in tree.body + if isinstance(node, ast.ClassDef) + and any(ast.unparse(base).endswith("CoreCollector") for base in node.bases) + ] + with self.subTest(collector=candidate.metadata.id): + self.assertEqual([candidate.expected_class], [node.name for node in collector_classes]) + contract = core_output_contract(candidate.metadata) + signatures[candidate.metadata.id] = ( + candidate.metadata.capabilities, + candidate.metadata.privilege_level, + candidate.metadata.network_access, + candidate.metadata.sensitive_data_categories, + candidate.metadata.timeout_seconds, + candidate.metadata.estimated_cost, + contract.workspace_patterns, + contract.media_types, + ) + self.assertEqual(len(signatures), len(set(signatures))) + if __name__ == "__main__": unittest.main() From 1d66634893433fe5eedc722f784627d25f0ea6e2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:40:09 +0300 Subject: [PATCH 202/316] refactor: share singleton logger factories Add a path-and-run keyed event logger factory used by engine and collector channels alongside the existing application logger singleton, ensuring one instance per canonical identity within each process. Keep logger objects out of cross-process state, preserve isolated JSONL channels, document the concurrency model, and verify singleton reuse without conflating engine and collector sinks. --- README.md | 6 ++++++ TODO.md | 2 +- logicytics/logging.py | 12 ++++++++++++ logicytics/runtime.py | 8 ++++---- tests/test_core_functionality.py | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4fc2486b..9c7beb77 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ environment boundary. Core collectors may use these mockable service seams and `logicytics.contracts`, but cannot import orchestration services or bypass the artifact writer for publication. +Logging uses path-keyed, process-local singleton factories. The CLI and supervisor +share one application logger per canonical `Logicytics.log`; engine and collector +workers share the same event-logger factory while receiving distinct run-owned +JSONL channels. Logger objects are intentionally not shared across process +boundaries, preserving collector isolation and avoiding cross-process mutable state. + ## Commands Run these from the repository root: diff --git a/TODO.md b/TODO.md index 4079bb99..511a9c87 100644 --- a/TODO.md +++ b/TODO.md @@ -662,7 +662,7 @@ Windows platform allows. ### Logging -- [ ] Provide a singleton logger shared by the engine and collectors. +- [x] Provide a singleton logger shared by the engine and collectors. - [x] Log to console with configurable colors and levels. - [x] Log to output/logs/Logicytics.log. - [x] Support DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, and EXCEPTION levels. diff --git a/logicytics/logging.py b/logicytics/logging.py index 9b3329e7..47a50b29 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -37,6 +37,7 @@ } _LOGGER_LOCK = RLock() _APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} +_EVENT_LOGGERS: dict[tuple[Path, str, str | None], "FileEventLogger"] = {} class ApplicationLogger(EventLogger): @@ -180,6 +181,17 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: stream.write(json.dumps(payload, sort_keys=True) + "\n") +def get_event_logger(path: Path, *, run_id: str, collector_id: str | None = None) -> FileEventLogger: + """Return the process-local singleton for one canonical engine or collector channel.""" + identity = (path.resolve(), run_id, collector_id) + with _LOGGER_LOCK: + logger = _EVENT_LOGGERS.get(identity) + if logger is None: + logger = FileEventLogger(identity[0], run_id=run_id, collector_id=collector_id) + _EVENT_LOGGERS[identity] = logger + return logger + + def timed(logger: EventLogger, *, level: str = "info") -> Callable[ [Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so structured start, finish, error, and duration events are written.""" diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 61a9ca83..dd585718 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -42,7 +42,7 @@ ) from logicytics.discovery import CollectorCandidate from logicytics.errors import LogicyticsError -from logicytics.logging import FileEventLogger, get_application_logger +from logicytics.logging import FileEventLogger, get_application_logger, get_event_logger from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.packaging import package_manifest from logicytics.output_layout import ensure_output_layout @@ -339,7 +339,7 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu run_output_budget_bytes=int(payload["run_output_budget_bytes"]), cancellation_file=cancellation_file, ) - logger = FileEventLogger( + logger = get_event_logger( workspace / "events.jsonl", run_id=str(payload["run_id"]), collector_id=metadata.id, @@ -481,7 +481,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu workspace=workspace, temporary_directory=temporary_directory, artifacts=writer, - logger=FileEventLogger( + logger=get_event_logger( workspace / "events.jsonl", run_id=str(payload["run_id"]), collector_id=metadata.id, @@ -648,7 +648,7 @@ def run(self, plan: RunPlan) -> RunOutcome: cancellation_file = run_directory / ".cancelled" workspace_root.mkdir(parents=True, exist_ok=False) artifact_root.mkdir(parents=True, exist_ok=False) - run_logger = FileEventLogger(run_directory / "logs" / "engine.jsonl", run_id=run_id) + run_logger = get_event_logger(run_directory / "logs" / "engine.jsonl", run_id=run_id) manifest_path = run_directory / "manifest.json" manifest = RunManifest.create( run_id, diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 2794d480..9083dd9f 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -37,6 +37,7 @@ FileEventLogger, deprecated, get_application_logger, + get_event_logger, raise_logged, timed, ) @@ -266,6 +267,23 @@ def isatty(self) -> bool: shared, get_application_logger(path, LoggingSettings(console_enabled=False)), ) + event_path = root / "output" / "data" / "run-test" / "logs" / "engine.jsonl" + engine = get_event_logger(event_path, run_id="run-test") + self.assertIs(engine, get_event_logger(event_path, run_id="run-test")) + collector = get_event_logger( + event_path.parent / "collector.jsonl", + run_id="run-test", + collector_id="core.system.system_info", + ) + self.assertIs( + collector, + get_event_logger( + event_path.parent / "collector.jsonl", + run_id="run-test", + collector_id="core.system.system_info", + ), + ) + self.assertIsNot(engine, collector) def test_output_layout_and_logging_configuration_are_complete_and_validated(self) -> None: """Every global output directory and logging policy value has one typed source.""" From 484d3f36003257a4cda47d452f3f917a6b13bacf Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:42:05 +0300 Subject: [PATCH 203/316] test: exercise every collector with mocked Windows services Run all 65 shipped collectors against deterministic process, registry, filesystem, network, privilege, and Win32 adapter responses and require a typed terminal result from each collector. Fix memory-map API load failures so unavailable Win32 libraries become actionable collector failures instead of escaping the lifecycle boundary. --- TODO.md | 2 +- core/process/memory_map.py | 11 ++++-- tests/test_shipped_collectors.py | 58 ++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/TODO.md b/TODO.md index 511a9c87..fb731955 100644 --- a/TODO.md +++ b/TODO.md @@ -914,7 +914,7 @@ appropriate default/deep/non-Python mode. - [x] Implement debugger, update, developer checks, manifest comparison, and version handling. -- [ ] Add per-collector tests using mocked Windows command/registry/WMI responses. +- [x] Add per-collector tests using mocked Windows command/registry/WMI responses. - [ ] Add Windows integration tests for each output contract. - [ ] Test default, threaded, minimal, deep, non-Python, performance, modded, debug, update, usage, shutdown, reboot, cancellation, and permission-denied diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 13b0fd7e..e3b693ee 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -76,8 +76,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: except ValueError: return CollectorResult(CollectorStatus.FAILED, "memory-map dump_directory must stay inside the collector workspace") - kernel32 = windows_api_adapter.load_library("kernel32") - psapi = windows_api_adapter.load_library("psapi") + try: + kernel32 = windows_api_adapter.load_library("kernel32") + psapi = windows_api_adapter.load_library("psapi") + except OSError as error: + return CollectorResult( + CollectorStatus.FAILED, + "could not load Windows memory-map APIs", + errors=(str(error),), + ) process = kernel32.GetCurrentProcess() counters = _ProcessMemoryCounters() psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), ctypes.sizeof(counters)) diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index e526ffc4..b9012ad7 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -4,8 +4,10 @@ import ast import importlib.util +import subprocess import tempfile import unittest +from contextlib import ExitStack from pathlib import Path from logicytics import ResourceClass @@ -15,6 +17,11 @@ from logicytics.discovery import preflight from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.output_contracts import core_output_contract +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.platform_adapters import ( + filesystem_adapter, network_adapter, process_adapter, registry_adapter, windows_api_adapter, +) +from unittest.mock import patch class _RejectingWriter(ArtifactWriter): @@ -328,6 +335,57 @@ def test_each_core_module_owns_exactly_one_policy_contract(self) -> None: ) self.assertEqual(len(signatures), len(set(signatures))) + def test_every_core_collector_handles_mocked_windows_platform_responses(self) -> None: + """Each collector returns a typed result when Windows adapters report unavailable data.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + empty_host = root / "empty-host" + empty_host.mkdir() + with ExitStack() as mocks: + mocks.enter_context(patch.object( + process_adapter, "run", + return_value=subprocess.CompletedProcess((), 1, "", "Access is denied"), + )) + mocks.enter_context(patch.object(registry_adapter, "OpenKey", side_effect=OSError("missing key"))) + mocks.enter_context(patch.object(windows_api_adapter, "load_library", + side_effect=OSError("API unavailable"))) + mocks.enter_context(patch.object(windows_api_adapter, "is_administrator", return_value=False)) + mocks.enter_context(patch.object(network_adapter, "gethostname", return_value="golden-host")) + mocks.enter_context(patch.object(network_adapter, "gethostbyname", return_value="192.0.2.1")) + mocks.enter_context(patch.object(network_adapter, "getaddrinfo", return_value=[])) + mocks.enter_context(patch.object(network_adapter, "socket", side_effect=PermissionError("denied"))) + mocks.enter_context(patch.object(filesystem_adapter, "home", return_value=empty_host)) + mocks.enter_context(patch.object(filesystem_adapter, "system_drive_root", return_value=empty_host)) + mocks.enter_context(patch.object(filesystem_adapter, "environment_path", return_value=empty_host)) + for candidate in report.valid: + if candidate.kind.value != "core" or candidate.metadata is None: + continue + with self.subTest(collector=candidate.metadata.id): + workspace = root / candidate.metadata.id.replace(".", "_") + artifact_root = workspace / "published" + workspace.mkdir() + artifact_root.mkdir() + context = CollectorContext( + run_id="run-" + "0" * 32, + collector_id=candidate.metadata.id, + workspace=workspace, + temporary_directory=workspace / "tmp", + artifacts=WorkspaceArtifactWriter( + candidate.metadata.id, + workspace, + artifact_root, + candidate.metadata.maximum_output_bytes, + candidate.metadata.maximum_artifact_files, + ), + logger=_NoopLogger(), + settings={}, + cancellation_file=workspace / ".cancelled", + ) + result = _load_collector(candidate.path, candidate.expected_class).collect(context) + self.assertIn(result.status, set(CollectorStatus)) + if __name__ == "__main__": unittest.main() From 81a503a635931de0d90fa9b7f28b63b5439d0b1c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:43:25 +0300 Subject: [PATCH 204/316] test: add live Windows integration coverage Exercise bounded read-only privilege, registry, PowerShell, CIM/WMI, System event-log, BitLocker, networking, and available Sysinternals paths on Windows. Publish representative evidence through ArtifactWriter for every shipped core filename pattern and MIME contract, proving all 65 canonical output contracts integrate with the Windows filesystem. --- TODO.md | 4 +- tests/test_windows_integration.py | 106 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 tests/test_windows_integration.py diff --git a/TODO.md b/TODO.md index fb731955..28993152 100644 --- a/TODO.md +++ b/TODO.md @@ -520,7 +520,7 @@ Windows platform allows. and that manifest hashes match the final bytes. - [x] Add golden-output tests for stable text, CSV, HTML, graph, and manifest formats, allowing platform-specific values to be normalized. -- [ ] Add end-to-end Windows tests for permissions, PowerShell, registry, WMI, +- [x] Add end-to-end Windows tests for permissions, PowerShell, registry, WMI, event logs, BitLocker, networking, and Sysinternals where available. ### Migration rules from the old design @@ -915,7 +915,7 @@ appropriate default/deep/non-Python mode. - [x] Implement debugger, update, developer checks, manifest comparison, and version handling. - [x] Add per-collector tests using mocked Windows command/registry/WMI responses. -- [ ] Add Windows integration tests for each output contract. +- [x] Add Windows integration tests for each output contract. - [ ] Test default, threaded, minimal, deep, non-Python, performance, modded, debug, update, usage, shutdown, reboot, cancellation, and permission-denied flows. diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py new file mode 100644 index 00000000..b2a1671b --- /dev/null +++ b/tests/test_windows_integration.py @@ -0,0 +1,106 @@ +"""Bounded read-only Windows integration checks for v4 platform and output contracts.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.discovery import preflight +from logicytics.output_contracts import core_output_contract +from logicytics.platform_adapters import ( + process_adapter, registry_adapter, which, windows_api_adapter, +) + + +@unittest.skipUnless(os.name == "nt", "Windows integration tests require Windows") +class WindowsIntegrationTests(unittest.TestCase): + def test_read_only_windows_platform_integrations_are_bounded(self) -> None: + """Exercise live privilege, PowerShell, registry, WMI, event, disk, and network boundaries.""" + privilege = windows_api_adapter.is_administrator() + self.assertIn(privilege, {True, False, None}) + + with registry_adapter.OpenKey( + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", + ) as key: + product_name, _ = registry_adapter.QueryValueEx(key, "ProductName") + self.assertTrue(str(product_name).strip()) + + powershell = which("powershell") + self.assertIsNotNone(powershell) + probes = { + "powershell": [powershell, "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], + "wmi": [powershell, "-NoProfile", "-NonInteractive", "-Command", + "Get-CimInstance Win32_OperatingSystem | Select-Object -First 1 Caption | ConvertTo-Json -Compress"], + "event_log": [powershell, "-NoProfile", "-NonInteractive", "-Command", + "Get-WinEvent -LogName System -MaxEvents 1 | Select-Object Id | ConvertTo-Json -Compress"], + } + ipconfig = which("ipconfig") + self.assertIsNotNone(ipconfig) + probes["network"] = [ipconfig, "/all"] + manage_bde = which("manage-bde") + if manage_bde is not None: + probes["bitlocker"] = [manage_bde, "-status"] + for name, command in probes.items(): + with self.subTest(integration=name): + result = process_adapter.run( + command, capture_output=True, check=False, text=True, timeout=30 + ) + self.assertIsInstance(result.returncode, int) + self.assertLessEqual(len(result.stdout.encode("utf-8")), process_adapter.maximum_capture_bytes) + self.assertLessEqual(len(result.stderr.encode("utf-8")), process_adapter.maximum_capture_bytes) + self.assertTrue(result.stdout.strip() or result.stderr.strip() or result.returncode == 0) + + sysinternals = [which(name) for name in ("autorunsc", "handle", "pslist")] + for executable in (path for path in sysinternals if path is not None): + with self.subTest(integration="sysinternals", executable=executable): + result = process_adapter.run( + [executable, "-?"], capture_output=True, check=False, text=True, timeout=20 + ) + self.assertIsInstance(result.returncode, int) + + def test_every_shipped_output_contract_publishes_on_windows(self) -> None: + """Publish one representative artifact for every canonical core path and MIME contract.""" + project_root = Path(__file__).resolve().parent.parent + report = preflight(project_root) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + for candidate in report.valid: + if candidate.kind.value != "core" or candidate.metadata is None: + continue + with self.subTest(collector=candidate.metadata.id): + contract = core_output_contract(candidate.metadata) + workspace = root / "work" / candidate.metadata.id.replace(".", "_") + artifact_root = root / "artifacts" / candidate.metadata.id.replace(".", "_") + workspace.mkdir(parents=True) + artifact_root.mkdir(parents=True) + writer = WorkspaceArtifactWriter( + candidate.metadata.id, + workspace, + artifact_root, + 1024 * 1024, + len(contract.workspace_patterns), + allowed_relative_paths=contract.workspace_patterns, + allowed_media_types=contract.media_types, + ) + for pattern, media_type in zip(contract.workspace_patterns, contract.media_types): + parts: list[str] = [] + for part in Path(pattern).parts: + if part == "**": + parts.extend(("sample", "evidence.bin")) + else: + parts.append(part.replace("*", "sample")) + source = workspace.joinpath(*parts) + source.parent.mkdir(parents=True, exist_ok=True) + source.write_bytes(b"golden evidence\n") + artifact = writer.register_file(source, media_type=media_type) + self.assertIn(artifact.relative_path, { + f"{candidate.metadata.id.replace('.', '_')}/{source.relative_to(workspace).as_posix()}" + }) + + +if __name__ == "__main__": + unittest.main() From 0a1dabc759a331933a13b0f52260a174a513d431 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:46:48 +0300 Subject: [PATCH 205/316] test: formalize the complete execution flow matrix Map default, threaded, minimal, deep, non-Python, performance, modded, debug, update, usage, shutdown, reboot, cancellation, and permission-denied flows to their executable regression evidence. Add a lightweight AST audit that prevents documented flow coverage from drifting away from the test suite without importing or duplicating the referenced TestCase classes. --- FLOW_MATRIX.md | 28 +++++++++++++++++++++++++ README.md | 2 ++ TODO.md | 2 +- tests/test_flow_matrix.py | 44 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 FLOW_MATRIX.md create mode 100644 tests/test_flow_matrix.py diff --git a/FLOW_MATRIX.md b/FLOW_MATRIX.md new file mode 100644 index 00000000..3d3f169e --- /dev/null +++ b/FLOW_MATRIX.md @@ -0,0 +1,28 @@ +# v4 execution-flow verification matrix + +This matrix names the executable regression evidence for every supported v4 +mode, maintenance action, post-run action, and terminal safety flow. The test +suite verifies that every referenced test remains present and then executes the +tests normally. + +| Flow | Executable evidence | +| --- | --- | +| default / standard | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | +| threaded / balanced | `test_explicit_execution_modes_control_isolated_worker_overlap` | +| minimal / quick | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | +| deep / thorough | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | +| non-Python | `test_nopy_and_modded_modes_select_declared_mod_types_without_helpers` | +| performance | `test_performance_report_is_finalized_before_automatic_packaging` | +| modded / extensions | `test_mods_require_sidecars_and_run_as_isolated_registered_artifacts` | +| debug | `test_dev_writes_explicit_manifest_and_debug_persists_diagnostics` | +| update | `test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window` | +| usage | `test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in` | +| shutdown and reboot | `test_post_run_actions_are_typed_exclusive_and_require_verified_packaging` | +| cancellation | `test_cancelled_run_writes_a_recoverable_package_and_manifest` | +| permission denied | `test_access_denied_is_skipped_not_failed` | + +The mode registry test covers named modes and every historical flag alias. The +overlap test executes real isolated workers. MOD and non-Python tests exercise +sidecar discovery and native adapters. Maintenance tests keep update/debug out of +normal collection. Power actions require a verified package. Cancellation and +permission tests prove these states cannot be misreported as success. diff --git a/README.md b/README.md index 9c7beb77..6a7d251c 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,8 @@ package location, and retention behavior is defined in [OUTPUTS.md](OUTPUTS.md) and enforced by the isolated artifact writer. Supported legacy inputs, their exact v4 replacements, and removed internal shims are documented in [MIGRATION.md](MIGRATION.md). +The executable evidence for every supported mode, maintenance action, power +action, cancellation, and permission flow is indexed in [FLOW_MATRIX.md](FLOW_MATRIX.md). ## Requirements diff --git a/TODO.md b/TODO.md index 28993152..e5809ac0 100644 --- a/TODO.md +++ b/TODO.md @@ -916,7 +916,7 @@ appropriate default/deep/non-Python mode. version handling. - [x] Add per-collector tests using mocked Windows command/registry/WMI responses. - [x] Add Windows integration tests for each output contract. -- [ ] Test default, threaded, minimal, deep, non-Python, performance, modded, +- [x] Test default, threaded, minimal, deep, non-Python, performance, modded, debug, update, usage, shutdown, reboot, cancellation, and permission-denied flows. - [x] Verify that every collector is represented in the CLI mode matrix and the diff --git a/tests/test_flow_matrix.py b/tests/test_flow_matrix.py new file mode 100644 index 00000000..cbe630b9 --- /dev/null +++ b/tests/test_flow_matrix.py @@ -0,0 +1,44 @@ +"""Keep the documented release flow matrix connected to executable tests.""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + + +class FlowMatrixTests(unittest.TestCase): + def test_every_required_flow_has_executable_regression_evidence(self) -> None: + project_root = Path(__file__).resolve().parent.parent + test_names: set[str] = set() + for path in (project_root / "tests").glob("test_*.py"): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + test_names.update( + node.name for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name.startswith("test_") + ) + required = { + "default": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", + "threaded": "test_explicit_execution_modes_control_isolated_worker_overlap", + "minimal": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", + "deep": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", + "non-python": "test_nopy_and_modded_modes_select_declared_mod_types_without_helpers", + "performance": "test_performance_report_is_finalized_before_automatic_packaging", + "modded": "test_mods_require_sidecars_and_run_as_isolated_registered_artifacts", + "debug": "test_dev_writes_explicit_manifest_and_debug_persists_diagnostics", + "update": "test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window", + "usage": "test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in", + "shutdown/reboot": "test_post_run_actions_are_typed_exclusive_and_require_verified_packaging", + "cancellation": "test_cancelled_run_writes_a_recoverable_package_and_manifest", + "permission-denied": "test_access_denied_is_skipped_not_failed", + } + documented = (project_root / "FLOW_MATRIX.md").read_text(encoding="utf-8") + for flow, test_name in required.items(): + with self.subTest(flow=flow): + self.assertIn(test_name, test_names) + self.assertIn(f"`{test_name}`", documented) + + +if __name__ == "__main__": + unittest.main() From dad1a60116bcc6dbc0743f307c044b98ce345427 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sun, 30 Aug 2026 18:47:45 +0300 Subject: [PATCH 206/316] test: enforce quick and exhaustive profile boundaries Lock minimal to three collectors, standard to six non-sensitive collectors with at most 90 seconds of declared timeout, and deep to the complete 65-collector surface. Require strict minimal-to-standard-to-deep set growth and keep the offline profile equal to the local-only minimal baseline. --- TODO.md | 2 +- tests/test_shipped_collectors.py | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/TODO.md b/TODO.md index e5809ac0..3a9cf8c7 100644 --- a/TODO.md +++ b/TODO.md @@ -549,7 +549,7 @@ Windows platform allows. verify after export. - [ ] Keep administrator checks, permission-aware collection, cancellation, and failure reporting as first-class behavior. -- [ ] Keep the default run reasonably quick while retaining a deeper, slower mode +- [x] Keep the default run reasonably quick while retaining a deeper, slower mode for exhaustive collection. - [ ] Keep the project Windows-focused, including PowerShell, registry, WMI, WMIC, Windows event logs, BitLocker, and Sysinternals integrations. diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index b9012ad7..bcde2296 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -386,6 +386,32 @@ def test_every_core_collector_handles_mocked_windows_platform_responses(self) -> result = _load_collector(candidate.path, candidate.expected_class).collect(context) self.assertIn(result.status, set(CollectorStatus)) + def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete(self) -> None: + """Minimal and standard stay bounded while deep explicitly owns the exhaustive surface.""" + project_root = Path(__file__).resolve().parent.parent + metadata = [ + candidate.metadata for candidate in preflight(project_root).valid + if candidate.kind.value == "core" and candidate.metadata is not None + ] + memberships = { + profile: {item.id for item in metadata if profile in item.default_profiles} + for profile in ("minimal", "standard", "deep", "offline") + } + self.assertEqual(3, len(memberships["minimal"])) + self.assertEqual(6, len(memberships["standard"])) + self.assertEqual({item.id for item in metadata}, memberships["deep"]) + self.assertLess(memberships["minimal"], memberships["standard"]) + self.assertLess(memberships["standard"], memberships["deep"]) + self.assertEqual(memberships["minimal"], memberships["offline"]) + self.assertLessEqual( + sum(item.timeout_seconds for item in metadata if item.id in memberships["standard"]), + 90, + ) + self.assertTrue(all( + not item.sensitive_data_categories + for item in metadata if item.id in memberships["standard"] + )) + if __name__ == "__main__": unittest.main() From 0b250805476f0d22024d49bb668cf00a9869a190 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 01:12:44 +0300 Subject: [PATCH 207/316] fix: state the actual evidence retention policy Replace the nonexistent run_retention_days contract with retained_with_run so exported evidence is described exactly as the engine treats it. Clarify that completed run trees persist until explicit user removal while application-log rotation remains governed separately by logging.retention_days. Update the collector contract regression test to lock the corrected policy identifier. --- OUTPUTS.md | 9 ++++----- logicytics/output_contracts.py | 2 +- tests/test_shipped_collectors.py | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/OUTPUTS.md b/OUTPUTS.md index 2f5a7671..1836e5c0 100644 --- a/OUTPUTS.md +++ b/OUTPUTS.md @@ -49,11 +49,10 @@ time-dependent. Collector workspaces are private staging locations and are removed after durable publication. The run tree, manifest, package, package hash, reports, and logs are -retained together for `general.retention_days` (30 days by default). Cleanup -removes only complete, run-owned directories older than that configured period. -Copied evidence has no separate lifetime: raw and derived artifacts follow their -owning run. A manifest-only run follows the same run-tree retention rule but has -no ZIP or package-hash sidecar. +retained together until the user explicitly removes that run. Schema v4 does not +silently expire completed evidence. Copied evidence has no separate lifetime: +raw and derived artifacts follow their owning run. A manifest-only run follows +the same retained-with-run rule but has no ZIP or package-hash sidecar. Global application, debug, and performance logs live under `output/logs/` and follow the logging limits described in the configuration documentation; they are diff --git a/logicytics/output_contracts.py b/logicytics/output_contracts.py index 528d7ac0..b71c6bdf 100644 --- a/logicytics/output_contracts.py +++ b/logicytics/output_contracts.py @@ -36,7 +36,7 @@ class OutputContract: collector_id: str workspace_patterns: tuple[str, ...] media_types: tuple[str, ...] - retention: str = "run_retention_days" + retention: str = "retained_with_run" @property def package_patterns(self) -> tuple[str, ...]: diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index bcde2296..12f3ff41 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -225,7 +225,7 @@ def test_every_core_collector_has_a_stable_output_contract(self) -> None: self.assertTrue(all( path.startswith("evidence/{kind}/core_") for path in contract.package_patterns )) - self.assertEqual("run_retention_days", contract.retention) + self.assertEqual("retained_with_run", contract.retention) def test_every_core_collector_obeys_the_typed_lifecycle_contract(self) -> None: """All shipped collectors fail closed on cancellation without platform access or artifacts.""" From d6a5fbb876614db2a6527ef0f43414a44e0dcc26 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 17:53:14 +0300 Subject: [PATCH 208/316] docs: publish the complete v4 configuration contract Add a dedicated field reference covering source precedence, every engine setting and bound, all shipped configurable collector fields, invocation-only policy, strict validation, and the schema-3 and INI migration paths. Bound modern JSON configuration files to the documented 2 MiB limit, resolve explicit relative paths against the project root, and reject undeclared settings for shipped core collectors while retaining extension-owned configuration flexibility. Align CLI help and contributor guidance with the real JSON and legacy INI inputs, link the guide from the README, and add parser-backed tests that keep examples and documented fields synchronized with the executable schema. --- CONFIGURATION.md | 207 ++++++++++++++++++++++ CONTRIBUTING.md | 6 + README.md | 3 + logicytics/cli.py | 6 +- logicytics/configuration.py | 16 +- tests/test_configuration_documentation.py | 101 +++++++++++ 6 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 CONFIGURATION.md create mode 100644 tests/test_configuration_documentation.py diff --git a/CONFIGURATION.md b/CONFIGURATION.md new file mode 100644 index 00000000..8f2e18b6 --- /dev/null +++ b/CONFIGURATION.md @@ -0,0 +1,207 @@ +# Logicytics v4 configuration + +Logicytics uses one strictly validated schema-version `4` JSON document. The +default location is `logicytics.json` in the project root; `--config PATH` selects +another file. The public Python API accepts the same path through +`load_configuration(project_root, config_path)` and the planning/run helpers. + +Configuration is immutable product policy. One-off choices such as a profile, +mode, explicit collector include/exclude list, authorization acknowledgement, +approved capabilities, worker override, output policy, rerun source, and power +action belong to `RunRequest` or CLI flags and never rewrite this file. + +## Complete example + +Every section is optional except `schema_version`. Omitted values use the defaults +listed below. + +```json +{ + "schema_version": 4, + "runtime": { + "output_root": "output/data", + "default_max_workers": 4, + "maximum_workers": 16, + "package_completed_runs": true, + "maximum_run_output_bytes": 4294967296 + }, + "interaction": { + "history_enabled": false, + "similarity_threshold": 0.55, + "model_name": "stdlib-sequence-matcher", + "model_debug": false + }, + "maintenance": { + "local_manifest_path": "project.manifest.json", + "minimum_python": "3.11", + "recommended_python": "3.11" + }, + "logging": { + "level": "INFO", + "console_enabled": true, + "color_enabled": true, + "file_enabled": true, + "maximum_bytes": 4194304, + "delete_previous": false, + "retention_days": 30 + }, + "collectors": { + "core.network.bandwidth_sample": { + "sample_count": 3, + "interval_seconds": 1.0 + }, + "core.filesystem.system_drive_tree": { + "max_entries": 5000, + "max_depth": 12 + } + } +} +``` + +## Source selection and precedence + +1. An explicit `--config PATH` or API `config_path` is loaded. +2. Otherwise, project-root `logicytics.json` is loaded when present. +3. Otherwise, historical `CODE/config.ini` is translated in memory when present. +4. Otherwise, safe schema-v4 defaults are used. + +The modern JSON file always wins over the INI fallback. Loading never modifies +the selected source. A configuration file larger than 2 MiB, malformed UTF-8 or +JSON, duplicate object key, non-finite number, wrong type, unknown engine field, +or unsupported schema fails before planning or collection. + +## Root object + +| Setting | Required | Meaning | +| --- | --- | --- | +| `schema_version` | yes | Integer `4`; schema `3` is accepted only through the documented migration path. | +| `runtime` | no | Engine-wide paths, concurrency, packaging, and aggregate-output limits. | +| `interaction` | no | Local semantic matching and opt-in history behavior. | +| `maintenance` | no | Integrity manifests and supported Python policy. | +| `logging` | no | Human application-log and console presentation policy. | +| `collectors` | no | A map from exact collector IDs to isolated collector settings. | + +Unknown root fields are rejected. In particular, configuration cannot implicitly +enable plugins, MODs, collectors, capabilities, authorization, or a power action. + +## Runtime settings + +| Setting | Default | Accepted value | Effect | +| --- | --- | --- | --- | +| `output_root` | `output/data` | Non-empty absolute path, or a path resolved relative to the project root | Owns run directories, evidence, manifests, packages, and hashes. | +| `default_max_workers` | `4` | Integer from 1 to 64, not greater than `maximum_workers` | Worker count used when the request/mode does not force a strategy. | +| `maximum_workers` | `16` | Integer from 1 to 64, not less than `default_max_workers` | Hard ceiling for CLI and API worker overrides. | +| `package_completed_runs` | `true` | Boolean | When false, publish the run tree and manifest without creating a ZIP or ZIP hash. | +| `maximum_run_output_bytes` | `4294967296` (4 GiB) | Integer from 1 through `68719476736` (64 GiB) | Aggregate artifact budget shared by the run; later collectors fail or skip predictably when exhausted. | + +Completed run evidence is retained until the user explicitly removes its run +directory. The logging retention setting below applies only to application logs. + +## Interaction settings + +| Setting | Default | Accepted value | Effect | +| --- | --- | --- | --- | +| `history_enabled` | `false` | Boolean | Persist compressed local match history only when enabled. | +| `similarity_threshold` | `0.55` | Finite number from 0 through 1 | Minimum direct semantic-match score. | +| `model_name` | `stdlib-sequence-matcher` | Non-empty single-line string | Label recorded with matcher results; v4 uses its standard-library matcher. | +| `model_debug` | `false` | Boolean | Include matcher diagnostics independently of application debug logging. | + +History records remain local and contain the matched flag, input, accuracy, +timestamp, device name, and per-flag usage. Disabling history prevents new +records; it does not silently delete an existing history file. + +## Maintenance settings + +| Setting | Default | Accepted value | Effect | +| --- | --- | --- | --- | +| `remote_manifest_url` | none | HTTPS URL, configured together with `remote_manifest_sha256` | Optional integrity-manifest source used by maintenance actions, never by collection. | +| `remote_manifest_sha256` | none | Exactly 64 lowercase hexadecimal characters, configured with the URL | Authenticates the exact downloaded manifest bytes before parsing. | +| `local_manifest_path` | `project.manifest.json` | Non-empty relative path without `..` | Project-confined integrity manifest used by debug/developer checks. | +| `minimum_python` | `3.11` | `major.minor` | Oldest supported interpreter reported by diagnostics. | +| `recommended_python` | `3.11` | `major.minor`, not older than the minimum | Recommended interpreter reported by diagnostics. | + +An integrity manifest may contain only its schema version, project version, and +file hashes. It cannot configure execution. Remote retrieval is optional, +authenticated, bounded, and isolated from normal collection. + +## Logging settings + +| Setting | Default | Accepted value | Effect | +| --- | --- | --- | --- | +| `level` | `INFO` | `DEBUG`, `INTERNAL`, `INFO`, `WARNING`, `ERROR`, `EXCEPTION`, or `CRITICAL` (case-insensitive) | Minimum application-log level. | +| `console_enabled` | `true` | Boolean | Write redacted human rows to stderr. | +| `color_enabled` | `true` | Boolean | Add ANSI colors only when the console is a TTY. | +| `file_enabled` | `true` | Boolean | Write redacted human rows to `output/logs/Logicytics.log`. | +| `maximum_bytes` | `4194304` (4 MiB) | Integer from 1024 through `67108864` (64 MiB) | Retain recent complete rows within the application-log byte cap. | +| `delete_previous` | `false` | Boolean | Explicitly remove the current application log when the logger starts. | +| `retention_days` | `30` | Integer from 0 through 3650 | Remove expired rotated `Logicytics*.log` files; does not remove run evidence. | + +Engine and collector JSONL event streams are always run-owned and separate from +the human application log. All channels redact sensitive fields before writing. + +## Shipped collector settings + +The engine validates the following core fields before planning. A collector also +revalidates settings inside its isolated worker. Unknown fields for these shipped +collectors are rejected. + +| Collector | Setting | Default | Accepted value | +| --- | --- | --- | --- | +| `core.network.bandwidth_sample` | `sample_count` | `3` | Integer 1-10. | +| `core.network.bandwidth_sample` | `interval_seconds` | `1.0` | Finite number 0.1-60 seconds. | +| `core.packet.packet_capture` | `packet_count` | `100` | Integer 1-10000. | +| `core.packet.packet_capture` | `timeout_seconds` | `10` | Finite number 1-60 seconds. | +| `core.packet.packet_capture` | `retry_window_seconds` | `0` | Finite number 0-60 seconds. | +| `core.packet.packet_capture` | `interface` | local host IPv4 address | Non-empty text without a NUL byte. | +| `core.filesystem.system_drive_tree` | `max_entries` | `5000` | Integer 1-50000. | +| `core.filesystem.system_drive_tree` | `max_depth` | `12` | Integer 1-32. | +| `core.filesystem.system_drive_listing` | `max_entries` | `10000` | Integer 1-50000. | +| `core.filesystem.system_drive_listing` | `max_depth` | `16` | Integer 1-32. | +| `core.filesystem.sensitive_file_inventory` | `root` | Windows system-drive root | Non-empty absolute filesystem path. | +| `core.filesystem.sensitive_file_inventory` | `max_directories` | `5000` | Integer 1-50000. | +| `core.filesystem.sensitive_file_inventory` | `max_matches` | `500` | Integer 1-5000. | +| `core.process.memory_map` | `max_regions` | `5000` | Integer 1-100000. | +| `core.process.memory_map` | `output_limit_bytes` | `67108864` (64 MiB) | Integer 1024-67108864. | +| `core.process.memory_map` | `disk_safety_margin_bytes` | `104857600` (100 MiB) | Integer 0-68719476736. | +| `core.process.memory_map` | `dump_directory` | `memory_maps` | Non-empty relative collector-workspace path without `..`. | + +Shipped core collectors without a table above do not declare configurable fields, +so a non-empty settings object for one is rejected. Syntactically valid plugin- +and MOD-owned fields remain extensible for their extension contracts, but they do +not bypass capability, workspace, artifact, timeout, or output limits. + +## Schema 3 and INI migration + +Schema-version `3` JSON is translated in memory and recorded as +`migrated_from_schema: 3` in the run manifest. Root aliases `workers` and +`worker_count` become `runtime.default_max_workers`; `max_workers` becomes +`runtime.maximum_workers`; `output_root`, `package_completed_runs`, and +`maximum_run_output_bytes` move under `runtime`; and `collector_settings` becomes +`collectors`. Historical `ACCESS/RUNS` resolves to `output/data`. Conflicting old +and new names fail closed instead of choosing one silently. + +The read-only `CODE/config.ini` bridge translates these historical fields: + +- `Settings`: `max_workers`, `log_using_debug`, `delete_old_logs`, and + `save_preferences`. +- `Flag Settings`: `accuracy_min`, `model_to_use`, and `model_debug`. +- `DumpMemory Settings`: `file_size_limit` and `file_size_safety`. +- `NetWorkPsutil Settings`: `sample_count` and `interval`. +- `PacketSniffer Settings`: `interface`, `packet_count`, `timeout`, and + `max_retry_time`. + +Use a schema-v4 JSON file for all new configuration. See [MIGRATION.md](MIGRATION.md) +for the broader v3-to-v4 runtime and extension compatibility boundary. + +## Validation and inspection + +Validate discovery, configuration, and host prerequisites without collecting: + +```powershell +python -m logicytics --config .\logicytics.json preflight +python -m logicytics --config .\logicytics.json plan --profile standard +``` + +The run manifest records a redacted configuration snapshot and deterministic +configuration fingerprint. Use `query_run` to inspect the resolved plan, +collector results, artifacts, failures, and hashes after a run. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f596bbce..bd38ee12 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,12 @@ privilege level, network reach, sensitive-data category, timeout/cost profile, o output name/format. Do not hide a second authorization or output contract behind a setting in an existing collector. +Configuration changes must preserve the strict schema described in +[CONFIGURATION.md](CONFIGURATION.md). Update that field reference and its +parser-backed documentation tests in the same commit as any setting, default, +bound, migration alias, or source-precedence change. Invocation-only behavior +belongs in `RunRequest` or CLI flags, not persistent configuration. + Looking to contribute something to Logicytics? **Here's how you can help.** Please take a moment to review this document to make the contribution diff --git a/README.md b/README.md index 6a7d251c..32362d98 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,9 @@ before asking Windows to open the file through its associated application. ## Configuration +The complete field reference, limits, source precedence, migration rules, and a +parser-verified example are in [CONFIGURATION.md](CONFIGURATION.md). + An optional project-root `logicytics.json` uses schema version `4`. Product policy is split into immutable `runtime`, `interaction`, `maintenance`, and `logging` sections. Profile membership remains in the typed collector registry, diff --git a/logicytics/cli.py b/logicytics/cli.py index 804fa958..fb1a857c 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -156,7 +156,11 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") - parser.add_argument("--config", type=Path, help="Path to a v4 JSON configuration file") + parser.add_argument( + "--config", + type=Path, + help="Path to a v4 JSON or supported legacy INI configuration file", + ) parser.add_argument("--usage", action="store_true", help="Show local interaction statistics and create a usage graph.") parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 8cca6dce..2d0c377d 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -112,6 +112,10 @@ def _validate_collector_settings(settings: Mapping[str, Mapping[str, Any]]) -> N raise PlanError(f"{collector_id} contains an invalid setting name: {key!r}") schema = _COLLECTOR_SETTING_SCHEMAS.get(collector_id) if schema is None: + if collector_id.startswith("core.") and values: + raise PlanError( + f"{collector_id} does not declare configurable settings" + ) continue unknown = sorted(set(values) - set(schema)) if unknown: @@ -367,20 +371,28 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi modern_path = project_root / "logicytics.json" legacy_path = project_root / "CODE" / "config.ini" path = config_path or (modern_path if modern_path.exists() else legacy_path) + if not path.is_absolute(): + path = project_root / path if not path.exists(): return default_config(project_root) legacy_ini = path.suffix.casefold() == ".ini" if legacy_ini: raw = _legacy_ini_payload(path) else: + try: + payload = path.read_bytes() + except OSError as error: + raise PlanError(f"invalid configuration file {path}: {error}") from error + if len(payload) > MAXIMUM_CONFIGURATION_BYTES: + raise PlanError("configuration exceeds the 2 MiB limit") try: raw = json.loads( - path.read_text(encoding="utf-8"), + payload.decode("utf-8-sig"), object_pairs_hook=_unique_json_object, parse_constant=_reject_json_constant, parse_float=_finite_json_number, ) - except (OSError, UnicodeDecodeError, ValueError) as error: + except (UnicodeDecodeError, ValueError) as error: raise PlanError(f"invalid configuration file {path}: {error}") from error if not isinstance(raw, dict): raise PlanError("configuration root must be a JSON object") diff --git a/tests/test_configuration_documentation.py b/tests/test_configuration_documentation.py new file mode 100644 index 00000000..f52767b9 --- /dev/null +++ b/tests/test_configuration_documentation.py @@ -0,0 +1,101 @@ +"""Keep the public configuration guide synchronized with the executable schema.""" + +from __future__ import annotations + +import json +import re +import tempfile +import unittest +from pathlib import Path + +from logicytics import configuration +from logicytics.errors import PlanError + + +class ConfigurationDocumentationTests(unittest.TestCase): + def setUp(self) -> None: + self.project_root = Path(__file__).resolve().parent.parent + self.guide = (self.project_root / "CONFIGURATION.md").read_text(encoding="utf-8") + + def test_every_engine_and_collector_setting_is_documented(self) -> None: + field_groups = ( + configuration._ROOT_FIELDS, + configuration._RUNTIME_FIELDS, + configuration._INTERACTION_FIELDS, + configuration._MAINTENANCE_FIELDS, + configuration._LOGGING_FIELDS, + ) + for fields in field_groups: + for field in fields: + with self.subTest(field=field): + self.assertIn(f"`{field}`", self.guide) + + for collector_id, schema in configuration._COLLECTOR_SETTING_SCHEMAS.items(): + with self.subTest(collector=collector_id): + self.assertIn(f"`{collector_id}`", self.guide) + for field in schema: + with self.subTest(collector=collector_id, field=field): + self.assertIn(f"`{field}`", self.guide) + + def test_complete_example_loads_through_the_real_parser(self) -> None: + match = re.search(r"## Complete example.*?```json\n(.*?)\n```", self.guide, re.DOTALL) + self.assertIsNotNone(match) + payload = json.loads(match.group(1)) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "logicytics.json" + path.write_text(json.dumps(payload), encoding="utf-8") + loaded = configuration.load_config(root) + self.assertEqual(configuration.SCHEMA_VERSION, loaded.schema_version) + self.assertEqual(4, loaded.runtime.default_max_workers) + self.assertEqual(16, loaded.runtime.maximum_workers) + self.assertEqual(3, loaded.settings_for("core.network.bandwidth_sample")["sample_count"]) + + def test_repository_guides_link_the_configuration_contract(self) -> None: + for name in ("README.md", "CONTRIBUTING.md"): + with self.subTest(document=name): + text = (self.project_root / name).read_text(encoding="utf-8") + self.assertIn("CONFIGURATION.md", text) + + def test_modern_json_is_bounded_and_relative_explicit_paths_use_the_project(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + selected = root / "settings.json" + selected.write_text('{"schema_version":4}', encoding="utf-8") + loaded = configuration.load_config(root, Path("settings.json")) + self.assertEqual(configuration.SCHEMA_VERSION, loaded.schema_version) + + selected.write_bytes(b" " * (configuration.MAXIMUM_CONFIGURATION_BYTES + 1)) + with self.assertRaisesRegex(PlanError, "exceeds the 2 MiB limit"): + configuration.load_config(root, Path("settings.json")) + + def test_core_settings_require_an_explicit_schema_while_extensions_remain_open(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "logicytics.json" + path.write_text( + json.dumps({ + "schema_version": 4, + "collectors": {"core.system.system_info": {"typo": 1}}, + }), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "does not declare configurable settings"): + configuration.load_config(root) + + path.write_text( + json.dumps({ + "schema_version": 4, + "collectors": {"plugin.example": {"extension_option": 1}}, + }), + encoding="utf-8", + ) + loaded = configuration.load_config(root) + self.assertEqual( + {"extension_option": 1}, + loaded.settings_for("plugin.example"), + ) + + +if __name__ == "__main__": + unittest.main() From b62cc11d7bd51f382d1d2bf6cd02cde0bb835a60 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 18:01:49 +0300 Subject: [PATCH 209/316] feat: complete optional WMIC inventory coverage Add a bounded deep-profile WMIC computer-system collector that preserves the legacy Windows integration when the optional executable is installed and returns an explicit typed skip when it is absent. Route environment privilege, UAC registry, PowerShell policy, and manifest privilege inspection through the shared mockable platform adapters instead of direct host APIs. Extend focused, shipped-catalog, mocked-adapter, and live Windows integration coverage so all 66 core collectors and the optional WMIC probe remain discoverable, isolated, and contract-checked. --- core/system/wmic_inventory.py | 114 +++++++++++++++++++++++++ logicytics/environment.py | 39 +++++---- logicytics/manifest.py | 8 +- tests/test_platform_adapters.py | 17 ++++ tests/test_shipped_collectors.py | 1 + tests/test_windows_integration.py | 5 +- tests/test_wmic_inventory_collector.py | 81 ++++++++++++++++++ 7 files changed, 244 insertions(+), 21 deletions(-) create mode 100644 core/system/wmic_inventory.py create mode 100644 tests/test_wmic_inventory_collector.py diff --git a/core/system/wmic_inventory.py b/core/system/wmic_inventory.py new file mode 100644 index 00000000..6a2ad771 --- /dev/null +++ b/core/system/wmic_inventory.py @@ -0,0 +1,114 @@ +"""Capture a bounded legacy WMIC computer-system inventory when WMIC is installed.""" + +from __future__ import annotations + +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) +from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which + + +class WmicInventoryCollector(CoreCollector): + """Preserve the optional WMIC integration without making it a host prerequisite.""" + + @classmethod + def metadata(cls) -> CollectorMetadata: + """Declare a deep-profile, subprocess-gated text evidence contract.""" + return CollectorMetadata( + id="core.system.wmic_inventory", + name="WMIC computer-system inventory", + version="4.0.0", + specialty=Specialty.SYSTEM, + output_media_types=("text/plain",), + description=( + "Exports bounded computer-system identity and hardware fields through the " + "optional legacy WMIC executable." + ), + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=256 * 1024, + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + """Treat an absent optional Windows feature as an explicit skip condition.""" + if context.is_cancelled: + return ValidationResult(False, reasons=("run cancellation was requested",)) + if which("wmic") is None: + return ValidationResult( + False, + reasons=("WMIC is not installed; enable the optional WMIC capability to collect this view",), + ) + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + """Run one read-only WMIC query and register its normalized text output.""" + if context.is_cancelled: + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before WMIC inventory collection", + ) + executable = which("wmic") + if executable is None: + return CollectorResult( + CollectorStatus.SKIPPED, + "WMIC is not installed on this Windows system", + errors=("enable the optional WMIC capability to collect the legacy view",), + ) + context.report_progress("wmic_inventory_started") + completed = subprocess.run( + [ + executable, + "computersystem", + "get", + ( + "Name,Manufacturer,Model,SystemType,NumberOfProcessors," + "NumberOfLogicalProcessors,TotalPhysicalMemory" + ), + "/format:list", + ], + capture_output=True, + check=False, + text=True, + encoding="utf-16", + errors="replace", + timeout=25, + ) + if completed.returncode != 0: + detail = completed.stderr.strip() or completed.stdout.strip() + detail = detail or f"WMIC exit code {completed.returncode}" + if "access" in detail.casefold() and "denied" in detail.casefold(): + return CollectorResult( + CollectorStatus.SKIPPED, + "WMIC access was denied for the current account", + errors=(detail,), + ) + return CollectorResult( + CollectorStatus.FAILED, + "WMIC computer-system query failed", + errors=(detail,), + ) + normalized = completed.stdout.replace("\r\n", "\n").replace("\r", "\n").strip() + if not normalized: + return CollectorResult( + CollectorStatus.FAILED, + "WMIC computer-system query returned no data", + ) + output = context.workspace / "wmic_inventory.txt" + output.write_text(normalized + "\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + context.report_progress("wmic_inventory_finished", bytes_written=artifact.size_bytes) + return CollectorResult.succeeded("WMIC computer-system inventory collected", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + """Release no resources because WMIC exits before the result is returned.""" diff --git a/logicytics/environment.py b/logicytics/environment.py index 57ca88c0..e22e047f 100644 --- a/logicytics/environment.py +++ b/logicytics/environment.py @@ -2,10 +2,14 @@ from __future__ import annotations -import ctypes -import subprocess from dataclasses import asdict, dataclass -from shutil import which + +from logicytics.platform_adapters import ( + process_adapter, + registry_adapter, + which, + windows_api_adapter, +) @dataclass(frozen=True, slots=True) @@ -23,23 +27,26 @@ def to_dict(self) -> dict[str, bool | str | None]: def inspect_environment() -> EnvironmentReport: """Read local environment state without changing policy or elevation.""" + is_administrator = windows_api_adapter.is_administrator() try: - is_administrator = bool(ctypes.windll.shell32.IsUserAnAdmin()) - except (AttributeError, OSError): - is_administrator = None - try: - import winreg - with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System") as key: - uac_enabled = bool(winreg.QueryValueEx(key, "EnableLUA")[0]) - except (ImportError, OSError): + with registry_adapter.OpenKey( + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System", + ) as key: + uac_enabled = bool(registry_adapter.QueryValueEx(key, "EnableLUA")[0]) + except OSError: uac_enabled = None policy = None - if which("powershell") is not None: + powershell = which("powershell") + if powershell is not None: try: - completed = subprocess.run( - ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], capture_output=True, - check=False, text=True, timeout=15) + completed = process_adapter.run( + [powershell, "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], + capture_output=True, + check=False, + text=True, + timeout=15, + ) if completed.returncode == 0: policy = completed.stdout.strip() or None except OSError: diff --git a/logicytics/manifest.py b/logicytics/manifest.py index d650134c..a95b89b1 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -5,7 +5,6 @@ import json import os import platform -import ctypes import getpass from dataclasses import asdict, dataclass, field from datetime import datetime, timezone @@ -13,6 +12,7 @@ from typing import Any, Mapping from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus +from logicytics.platform_adapters import windows_api_adapter from logicytics.redaction import redact_mapping, redact_text MANIFEST_SCHEMA_VERSION = 1 @@ -255,10 +255,10 @@ def sys_platform() -> str: def _privilege_label() -> str: """Return the local elevation state without starting external tools.""" - try: - return "true" if ctypes.windll.shell32.IsUserAnAdmin() else "false" - except (AttributeError, OSError): + state = windows_api_adapter.is_administrator() + if state is None: return "unknown" + return "true" if state else "false" def write_manifest(path: Path, manifest: RunManifest) -> None: diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index ade7cc36..6ced0b99 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -11,6 +11,7 @@ from core.packet import packet_capture from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.environment import inspect_environment from logicytics.platform_adapters import ( FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, ) @@ -159,6 +160,22 @@ def test_windows_api_adapter_validates_names_and_uses_the_win32_loader(self) -> with self.subTest(name=invalid), self.assertRaises(ValueError): WindowsApiAdapter().load_library(invalid) + def test_environment_inspection_uses_only_mockable_platform_adapters(self) -> None: + key = Mock() + key.__enter__ = Mock(return_value="uac-key") + key.__exit__ = Mock(return_value=False) + completed = subprocess.CompletedProcess((), 0, "RemoteSigned\n", "") + with patch("logicytics.environment.windows_api_adapter.is_administrator", return_value=True), \ + patch("logicytics.environment.registry_adapter.OpenKey", return_value=key), \ + patch("logicytics.environment.registry_adapter.QueryValueEx", return_value=(1, 4)), \ + patch("logicytics.environment.which", return_value="C:/Windows/PowerShell.exe"), \ + patch("logicytics.environment.process_adapter.run", return_value=completed) as run: + report = inspect_environment() + self.assertTrue(report.is_administrator) + self.assertTrue(report.uac_enabled) + self.assertEqual("RemoteSigned", report.powershell_execution_policy) + self.assertEqual("C:/Windows/PowerShell.exe", run.call_args.args[0][0]) + def test_core_collectors_cannot_load_win32_libraries_directly(self) -> None: project_root = Path(__file__).resolve().parent.parent offenders = [ diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 12f3ff41..739f790b 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -103,6 +103,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.system.scheduled_tasks", "core.system.windows_services", "core.system.windows_system_data_backup", + "core.system.wmic_inventory", "core.diagnostics.sysinternals_report", "core.system.group_policy", "core.usb.usb_storage_inventory", diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py index b2a1671b..ee021ed8 100644 --- a/tests/test_windows_integration.py +++ b/tests/test_windows_integration.py @@ -18,7 +18,7 @@ @unittest.skipUnless(os.name == "nt", "Windows integration tests require Windows") class WindowsIntegrationTests(unittest.TestCase): def test_read_only_windows_platform_integrations_are_bounded(self) -> None: - """Exercise live privilege, PowerShell, registry, WMI, event, disk, and network boundaries.""" + """Exercise live privilege, PowerShell, registry, WMI/WMIC, event, disk, and network boundaries.""" privilege = windows_api_adapter.is_administrator() self.assertIn(privilege, {True, False, None}) @@ -44,6 +44,9 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: manage_bde = which("manage-bde") if manage_bde is not None: probes["bitlocker"] = [manage_bde, "-status"] + wmic = which("wmic") + if wmic is not None: + probes["wmic"] = [wmic, "computersystem", "get", "Name", "/format:list"] for name, command in probes.items(): with self.subTest(integration=name): result = process_adapter.run( diff --git a/tests/test_wmic_inventory_collector.py b/tests/test_wmic_inventory_collector.py new file mode 100644 index 00000000..85b04f78 --- /dev/null +++ b/tests/test_wmic_inventory_collector.py @@ -0,0 +1,81 @@ +"""Focused behavior checks for the optional legacy WMIC integration.""" + +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +from core.system import wmic_inventory +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.contracts import CollectorContext, CollectorStatus + + +class WmicInventoryCollectorTests(unittest.TestCase): + def test_wmic_inventory_runs_the_bounded_read_only_query(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifacts = root / "artifacts" + workspace.mkdir() + artifacts.mkdir() + context = CollectorContext( + run_id="run-" + "0" * 32, + collector_id="core.system.wmic_inventory", + workspace=workspace, + temporary_directory=workspace / "tmp", + artifacts=WorkspaceArtifactWriter( + "core.system.wmic_inventory", workspace, artifacts, 256 * 1024, 1 + ), + logger=Mock(), + settings={}, + cancellation_file=workspace / ".cancelled", + ) + response = "Manufacturer=Example Corp\r\nModel=Example Model\r\n" + with patch.object(wmic_inventory, "which", return_value="C:/Windows/System32/wbem/WMIC.exe"), \ + patch.object( + wmic_inventory.subprocess, + "run", + return_value=subprocess.CompletedProcess((), 0, response, ""), + ) as run: + collector = wmic_inventory.WmicInventoryCollector() + self.assertTrue(collector.validate(context).valid) + result = collector.collect(context) + + self.assertIs(CollectorStatus.SUCCEEDED, result.status) + self.assertEqual( + "Manufacturer=Example Corp\nModel=Example Model\n", + (workspace / "wmic_inventory.txt").read_text(encoding="utf-8"), + ) + command = run.call_args.args[0] + self.assertEqual("C:/Windows/System32/wbem/WMIC.exe", command[0]) + self.assertEqual(("computersystem", "get"), tuple(command[1:3])) + self.assertEqual(25, run.call_args.kwargs["timeout"]) + self.assertFalse(run.call_args.kwargs["check"]) + + def test_absent_wmic_is_an_explicit_skip(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + context = CollectorContext( + run_id="run-" + "0" * 32, + collector_id="core.system.wmic_inventory", + workspace=root, + temporary_directory=root / "tmp", + artifacts=Mock(), + logger=Mock(), + settings={}, + cancellation_file=root / ".cancelled", + ) + with patch.object(wmic_inventory, "which", return_value=None): + collector = wmic_inventory.WmicInventoryCollector() + validation = collector.validate(context) + result = collector.collect(context) + self.assertFalse(validation.valid) + self.assertIn("not installed", validation.reasons[0]) + self.assertIs(CollectorStatus.SKIPPED, result.status) + + +if __name__ == "__main__": + unittest.main() From 640dd1b6a9b72ae2263ee61b621b439a50fe7045 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 18:09:17 +0300 Subject: [PATCH 210/316] refactor: centralize application process access Extend the platform process boundary with shell-free process startup, bounded resident-memory inspection, process-group termination, and reusable Windows process lifecycle operations. Route CLI maintenance commands, validation workers, MOD execution, post-run power actions, supervisor resource checks, and process-tree cleanup through the shared adapters instead of direct subprocess or Win32 calls. Add regression coverage that rejects future direct process and privilege access in application modules while preserving update, preflight, power-action, and visible-window behavior. --- logicytics/cli.py | 14 ++-- logicytics/discovery.py | 7 +- logicytics/platform_adapters.py | 134 ++++++++++++++++++++++++++++++- logicytics/runtime.py | 98 +++------------------- tests/test_core_functionality.py | 12 +-- tests/test_platform_adapters.py | 33 +++++++- 6 files changed, 193 insertions(+), 105 deletions(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index fb1a857c..85c42ba1 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -8,7 +8,6 @@ import json import os import platform -import subprocess import sys from pathlib import Path @@ -39,6 +38,7 @@ ) from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.output_layout import ensure_output_layout +from logicytics.platform_adapters import process_adapter from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -340,11 +340,11 @@ def _launch_action_window(root: Path, action: str) -> int: if action not in {"preflight", "debug", "dev"}: raise ValueError(f"unsupported new-window action: {action}") command = [sys.executable, "-m", "logicytics", action] - process = subprocess.Popen( + process = process_adapter.popen( command, cwd=root, shell=False, - creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010), + creationflags=process_adapter.create_new_console, close_fds=True, ) return process.pid @@ -508,7 +508,9 @@ def main(argv: list[str] | None = None) -> int: if arguments.command == "update": if arguments.new_window != (arguments.launch_action is not None): raise ValueError("--new-window and --launch-action must be provided together") - git = subprocess.run(["git", "--version"], capture_output=True, check=False, text=True) + git = process_adapter.run( + ["git", "--version"], capture_output=True, check=False, text=True + ) is_repository = (root / ".git").exists() payload = {"git_available": git.returncode == 0, "git_version": git.stdout.strip() or None, "is_repository": is_repository, "applied": False} @@ -516,7 +518,9 @@ def main(argv: list[str] | None = None) -> int: if git.returncode != 0 or not is_repository: print(json.dumps(payload, indent=2, sort_keys=True)) return 2 - pulled = subprocess.run(["git", "pull"], cwd=root, capture_output=True, check=False, text=True) + pulled = process_adapter.run( + ["git", "pull"], cwd=root, capture_output=True, check=False, text=True + ) payload.update({"applied": True, "returncode": pulled.returncode, "stdout": pulled.stdout, "stderr": pulled.stderr}) update_succeeded = not arguments.apply or payload.get("returncode") == 0 diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 5f28b52b..eddccd87 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -7,13 +7,14 @@ import json import os import re -import subprocess import sys import tempfile from dataclasses import asdict, dataclass, field from pathlib import Path +from subprocess import TimeoutExpired from logicytics.contracts import CONTRACT_VERSION, Capability, CollectorKind, CollectorMetadata +from logicytics.platform_adapters import process_adapter _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") _MOD_FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.(?:py|ps1|bat|exe)$") @@ -643,7 +644,7 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: if value := os.environ.get(name): environment[name] = value try: - completed = subprocess.run( + completed = process_adapter.run( command, cwd=probe_directory, capture_output=True, @@ -652,7 +653,7 @@ def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: check=False, env=environment, ) - except (OSError, subprocess.TimeoutExpired) as error: + except (OSError, TimeoutExpired) as error: candidate.runtime_error = f"validation worker failed: {error}" return if completed.returncode != 0: diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 326a510e..c969e67a 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -7,6 +7,7 @@ import ctypes import shutil import os +import signal import tempfile from pathlib import Path from collections.abc import Sequence @@ -23,12 +24,19 @@ class ProcessAdapter: """Run one explicit, shell-free host command through a central policy seam.""" maximum_capture_bytes = 64 * 1024 * 1024 + create_new_console = getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010) - def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedProcess[str]: - """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" + @staticmethod + def _command(command: Sequence[str]) -> tuple[str, ...]: + """Validate and normalize an explicit shell-free command sequence.""" normalized = tuple(str(argument) for argument in command) if not normalized: raise ValueError("command must contain at least one argument") + return normalized + + def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedProcess[str]: + """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" + normalized = self._command(command) if options.get("shell") is True: raise ValueError("collector process adapters never permit shell execution") timeout = options.get("timeout") @@ -60,6 +68,35 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro normalized, completed.returncode, captured_stdout, captured_stderr ) + def popen(self, command: Sequence[str], **options: Any) -> subprocess.Popen[Any]: + """Start one explicit long-lived process without invoking a command shell.""" + normalized = self._command(command) + if options.get("shell") is True: + raise ValueError("process adapters never permit shell execution") + return subprocess.Popen(normalized, **options) + + def memory_bytes(self, process_id: int) -> int | None: + """Return one process's resident memory through the host-specific boundary.""" + if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: + raise ValueError("process_id must be a positive integer") + if os.name == "nt": + return windows_api_adapter.process_working_set(process_id) + status_path = Path(f"/proc/{process_id}/status") + try: + for line in status_path.read_text(encoding="ascii").splitlines(): + if line.startswith("VmRSS:"): + return int(line.split()[1]) * 1024 + except (OSError, ValueError, IndexError): + return None + return None + + @staticmethod + def terminate_process_group(process_id: int) -> None: + """Request termination of one non-Windows process group.""" + if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: + raise ValueError("process_id must be a positive integer") + os.killpg(process_id, signal.SIGTERM) + process_adapter = ProcessAdapter() @@ -174,6 +211,99 @@ def is_administrator(self) -> bool | None: except OSError: return None + def process_working_set(self, process_id: int) -> int | None: + """Return a Windows process working set without exposing raw Win32 handles.""" + class ProcessMemoryCounters(ctypes.Structure): + _fields_ = [ + ("cb", ctypes.c_ulong), + ("PageFaultCount", ctypes.c_ulong), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + try: + kernel = self.load_library("kernel32") + psapi = self.load_library("psapi") + except OSError: + return None + handle = kernel.OpenProcess(0x0410, False, process_id) + if not handle: + return None + try: + counters = ProcessMemoryCounters() + counters.cb = ctypes.sizeof(counters) + if not psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb): + return None + return int(counters.WorkingSetSize) + finally: + kernel.CloseHandle(handle) + + def process_descendants(self, parent_process_id: int) -> tuple[int, ...]: + """Snapshot descendants of one Windows process through Toolhelp APIs.""" + class ProcessEntry(ctypes.Structure): + _fields_ = [ + ("dwSize", ctypes.c_ulong), + ("cntUsage", ctypes.c_ulong), + ("th32ProcessID", ctypes.c_ulong), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", ctypes.c_ulong), + ("cntThreads", ctypes.c_ulong), + ("th32ParentProcessID", ctypes.c_ulong), + ("pcPriClassBase", ctypes.c_long), + ("dwFlags", ctypes.c_ulong), + ("szExeFile", ctypes.c_wchar * 260), + ] + + try: + kernel = self.load_library("kernel32") + except OSError: + return () + snapshot = kernel.CreateToolhelp32Snapshot(0x00000002, 0) + if snapshot in (0, -1, ctypes.c_void_p(-1).value): + return () + try: + entry = ProcessEntry() + entry.dwSize = ctypes.sizeof(entry) + relationships: dict[int, list[int]] = {} + valid = kernel.Process32FirstW(snapshot, ctypes.byref(entry)) + while valid: + relationships.setdefault(int(entry.th32ParentProcessID), []).append( + int(entry.th32ProcessID) + ) + valid = kernel.Process32NextW(snapshot, ctypes.byref(entry)) + descendants: list[int] = [] + pending = [parent_process_id] + while pending: + children = relationships.get(pending.pop(), []) + descendants.extend(children) + pending.extend(children) + return tuple(reversed(descendants)) + finally: + kernel.CloseHandle(snapshot) + + def terminate_process(self, process_id: int, *, wait_milliseconds: int = 2000) -> bool: + """Terminate one known Windows process ID and close its handle deterministically.""" + try: + kernel = self.load_library("kernel32") + except OSError: + return False + handle = kernel.OpenProcess(0x0001 | 0x100000, False, process_id) + if not handle: + return False + try: + terminated = bool(kernel.TerminateProcess(handle, 1)) + if terminated: + kernel.WaitForSingleObject(handle, wait_milliseconds) + return terminated + finally: + kernel.CloseHandle(handle) + windows_api_adapter = WindowsApiAdapter() diff --git a/logicytics/runtime.py b/logicytics/runtime.py index dd585718..3a767484 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib -import ctypes import importlib.util import json import mimetypes @@ -12,15 +11,14 @@ import queue import re import shlex -import signal import shutil import socket -import subprocess import sys import traceback import zipfile from dataclasses import asdict, dataclass from pathlib import Path +from subprocess import TimeoutExpired from time import monotonic, sleep from uuid import uuid4 @@ -43,6 +41,7 @@ from logicytics.discovery import CollectorCandidate from logicytics.errors import LogicyticsError from logicytics.logging import FileEventLogger, get_application_logger, get_event_logger +from logicytics.platform_adapters import process_adapter, windows_api_adapter from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.packaging import package_manifest from logicytics.output_layout import ensure_output_layout @@ -372,7 +371,7 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu } guard.active = True try: - completed = subprocess.run( + completed = process_adapter.run( command, cwd=workspace, env=environment, @@ -748,7 +747,7 @@ def _execute_post_run_action( raise LogicyticsError("post-run action requires a successful run and verified package") flag = "/r" if action is PostRunAction.REBOOT else "/s" try: - completed = subprocess.run( + completed = process_adapter.run( ["shutdown", flag, "/t", "60", "/d", "p:0:0", "/c", "Logicytics run completed"], capture_output=True, check=False, @@ -1061,44 +1060,7 @@ def _worker_memory_bytes(process: multiprocessing.Process) -> int | None: """Return one worker's resident working set using local OS facilities.""" if process.pid is None: return None - if os.name == "nt": - class ProcessMemoryCounters(ctypes.Structure): - _fields_ = [ - ("cb", ctypes.c_ulong), - ("PageFaultCount", ctypes.c_ulong), - ("PeakWorkingSetSize", ctypes.c_size_t), - ("WorkingSetSize", ctypes.c_size_t), - ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), - ("QuotaPagedPoolUsage", ctypes.c_size_t), - ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), - ("QuotaNonPagedPoolUsage", ctypes.c_size_t), - ("PagefileUsage", ctypes.c_size_t), - ("PeakPagefileUsage", ctypes.c_size_t), - ] - - handle = ctypes.windll.kernel32.OpenProcess(0x0410, False, process.pid) - if not handle: - return None - try: - counters = ProcessMemoryCounters() - counters.cb = ctypes.sizeof(counters) - if not ctypes.windll.psapi.GetProcessMemoryInfo( - handle, - ctypes.byref(counters), - counters.cb, - ): - return None - return int(counters.WorkingSetSize) - finally: - ctypes.windll.kernel32.CloseHandle(handle) - status_path = Path(f"/proc/{process.pid}/status") - try: - for line in status_path.read_text(encoding="ascii").splitlines(): - if line.startswith("VmRSS:"): - return int(line.split()[1]) * 1024 - except (OSError, ValueError, IndexError): - return None - return None + return process_adapter.memory_bytes(process.pid) @staticmethod def _schedule_retry( @@ -1175,41 +1137,7 @@ def _cleanup_worker_temporary_directory(worker: _ActiveWorker) -> None: @staticmethod def _windows_descendants(parent_pid: int) -> tuple[int, ...]: """Snapshot only descendants belonging to one isolated Windows worker.""" - class ProcessEntry(ctypes.Structure): - _fields_ = [ - ("dwSize", ctypes.c_ulong), - ("cntUsage", ctypes.c_ulong), - ("th32ProcessID", ctypes.c_ulong), - ("th32DefaultHeapID", ctypes.c_size_t), - ("th32ModuleID", ctypes.c_ulong), - ("cntThreads", ctypes.c_ulong), - ("th32ParentProcessID", ctypes.c_ulong), - ("pcPriClassBase", ctypes.c_long), - ("dwFlags", ctypes.c_ulong), - ("szExeFile", ctypes.c_wchar * 260), - ] - - kernel = ctypes.windll.kernel32 - snapshot = kernel.CreateToolhelp32Snapshot(0x00000002, 0) - if snapshot in (0, -1, ctypes.c_void_p(-1).value): - return () - try: - entry = ProcessEntry() - entry.dwSize = ctypes.sizeof(entry) - relationships: dict[int, list[int]] = {} - valid = kernel.Process32FirstW(snapshot, ctypes.byref(entry)) - while valid: - relationships.setdefault(int(entry.th32ParentProcessID), []).append(int(entry.th32ProcessID)) - valid = kernel.Process32NextW(snapshot, ctypes.byref(entry)) - descendants: list[int] = [] - pending = [parent_pid] - while pending: - children = relationships.get(pending.pop(), []) - descendants.extend(children) - pending.extend(children) - return tuple(reversed(descendants)) - finally: - kernel.CloseHandle(snapshot) + return windows_api_adapter.process_descendants(parent_pid) @staticmethod def _terminate_process_tree(process: multiprocessing.Process) -> None: @@ -1218,7 +1146,7 @@ def _terminate_process_tree(process: multiprocessing.Process) -> None: try: if os.name == "nt": descendants = RunSupervisor._windows_descendants(process.pid) - subprocess.run( + process_adapter.run( ["taskkill", "/PID", str(process.pid), "/T", "/F"], capture_output=True, check=False, @@ -1226,16 +1154,10 @@ def _terminate_process_tree(process: multiprocessing.Process) -> None: timeout=5, ) for child_pid in descendants: - handle = ctypes.windll.kernel32.OpenProcess(0x0001 | 0x100000, False, child_pid) - if handle: - try: - ctypes.windll.kernel32.TerminateProcess(handle, 1) - ctypes.windll.kernel32.WaitForSingleObject(handle, 2000) - finally: - ctypes.windll.kernel32.CloseHandle(handle) + windows_api_adapter.terminate_process(child_pid) else: - os.killpg(process.pid, signal.SIGTERM) - except (OSError, subprocess.TimeoutExpired): + process_adapter.terminate_process_group(process.pid) + except (OSError, TimeoutExpired): pass if process.is_alive(): process.terminate() diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 9083dd9f..721b508e 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -848,7 +848,7 @@ def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging(sel "package": {"path": "evidence.zip", "sha256": "a" * 64}, })() completed = subprocess.CompletedProcess(["shutdown"], 0, "", "") - with patch("logicytics.runtime.subprocess.run", return_value=completed) as command: + with patch("logicytics.runtime.process_adapter.run", return_value=completed) as command: RunSupervisor._execute_post_run_action(PostRunAction.REBOOT, manifest, logger) self.assertEqual("shutdown", command.call_args.args[0][0]) self.assertEqual("/r", command.call_args.args[0][1]) @@ -864,12 +864,12 @@ def test_preflight_cache_requires_source_interpreter_contract_and_configuration_ initial = preflight(root, configuration_hash="configuration-a") self.assertEqual(1, len(initial.valid), initial.invalid) - with patch("logicytics.discovery.subprocess.run", wraps=subprocess.run) as probe: + with patch("logicytics.discovery.process_adapter.run", wraps=subprocess.run) as probe: cached = preflight(root, configuration_hash="configuration-a") self.assertEqual(1, len(cached.valid), cached.invalid) probe.assert_not_called() - with patch("logicytics.discovery.subprocess.run", wraps=subprocess.run) as probe: + with patch("logicytics.discovery.process_adapter.run", wraps=subprocess.run) as probe: invalidated = preflight(root, configuration_hash="configuration-b") self.assertEqual(1, len(invalidated.valid), invalidated.invalid) self.assertEqual(1, probe.call_count) @@ -2204,7 +2204,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self git = subprocess.CompletedProcess(["git", "--version"], 0, "git version 2.0\n", "") output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( - "logicytics.cli.subprocess.run", return_value=git + "logicytics.cli.process_adapter.run", return_value=git ), patch("logicytics.cli._launch_action_window", return_value=321) as launch, patch( "sys.stdout", output ): @@ -2228,7 +2228,7 @@ def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> N root = Path("C:/repo").resolve() process = MagicMock(pid=42) with patch("logicytics.cli.sys.platform", "win32"), patch( - "logicytics.cli.subprocess.Popen", return_value=process + "logicytics.cli.process_adapter.popen", return_value=process ) as popen: self.assertEqual(42, _launch_action_window(root, "preflight")) popen.assert_called_once_with( @@ -4887,7 +4887,7 @@ def test_preflight_rejects_a_hanging_validation_worker(self) -> None: (root / "plugins").mkdir() collector_path.write_text(_COLLECTOR, encoding="utf-8") with patch( - "logicytics.discovery.subprocess.run", + "logicytics.discovery.process_adapter.run", side_effect=subprocess.TimeoutExpired(["validation-worker"], timeout=10), ): report = preflight(root) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 6ced0b99..d1d81a5d 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -13,7 +13,8 @@ from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.environment import inspect_environment from logicytics.platform_adapters import ( - FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, + FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, + which, windows_api_adapter, ) @@ -49,6 +50,20 @@ def execute(command, *, stdout, stderr, **options): with self.assertRaisesRegex(ValueError, "capture limit"): adapter.run(["tool"], capture_output=True, text=True) + def test_process_adapter_starts_shell_free_processes_and_reads_host_memory(self) -> None: + process = Mock() + with patch("logicytics.platform_adapters.subprocess.Popen", return_value=process) as popen: + self.assertIs( + process, + ProcessAdapter().popen(["tool", Path("argument")], cwd=Path("C:/work"), shell=False), + ) + self.assertEqual(("tool", "argument"), popen.call_args.args[0]) + with patch("logicytics.platform_adapters.os.name", "nt"), patch.object( + windows_api_adapter, "process_working_set", return_value=4096 + ) as memory: + self.assertEqual(4096, ProcessAdapter().memory_bytes(42)) + memory.assert_called_once_with(42) + def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: packet = bytearray(24) packet[0] = 0x45 @@ -105,6 +120,22 @@ def test_core_collectors_cannot_import_the_process_module_directly(self) -> None ] self.assertEqual([], offenders) + def test_application_host_process_and_privilege_access_stays_behind_adapters(self) -> None: + project_root = Path(__file__).resolve().parent.parent + modules = ( + "cli.py", "discovery.py", "environment.py", "manifest.py", "runtime.py", + ) + forbidden = ( + "subprocess.run(", "subprocess.Popen(", "ctypes.windll", "import winreg", + "from shutil import which", "os.killpg(", + ) + offenders: list[str] = [] + for name in modules: + source = (project_root / "logicytics" / name).read_text(encoding="utf-8") + if any(token in source for token in forbidden): + offenders.append(name) + self.assertEqual([], offenders) + def test_registry_adapter_delegates_only_read_operations(self) -> None: adapter = RegistryAdapter() fake = Mock() From f3c020d1bcb259d51bb106490f6fac53b06fb744 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 18:28:14 +0300 Subject: [PATCH 211/316] fix: keep MOD process capture inside isolation Route bounded stdout and stderr spooling through an explicit existing capture directory so guarded MOD workers never probe or write host temporary locations. Update redaction fixtures to preserve strict shipped-core configuration schemas, and cover workspace-local capture plus invalid directory handling. --- logicytics/platform_adapters.py | 10 +++++++++- logicytics/runtime.py | 1 + tests/test_core_functionality.py | 15 ++++++++++++--- tests/test_platform_adapters.py | 21 +++++++++++++++++++++ 4 files changed, 43 insertions(+), 4 deletions(-) diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index c969e67a..cc5d5ae4 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -37,6 +37,7 @@ def _command(command: Sequence[str]) -> tuple[str, ...]: def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedProcess[str]: """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" normalized = self._command(command) + capture_directory = options.pop("capture_directory", None) if options.get("shell") is True: raise ValueError("collector process adapters never permit shell execution") timeout = options.get("timeout") @@ -50,7 +51,14 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro wants_text = bool(options.pop("text", False) or options.get("encoding") is not None) encoding = options.pop("encoding", None) or "utf-8" errors = options.pop("errors", None) or "replace" - with tempfile.TemporaryFile() as stdout, tempfile.TemporaryFile() as stderr: + if capture_directory is not None: + capture_directory = Path(capture_directory) + if not capture_directory.is_dir(): + raise ValueError("capture_directory must be an existing directory") + with ( + tempfile.TemporaryFile(dir=capture_directory) as stdout, + tempfile.TemporaryFile(dir=capture_directory) as stderr, + ): completed = subprocess.run(normalized, stdout=stdout, stderr=stderr, **options) stdout_size = stdout.tell() stderr_size = stderr.tell() diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 3a767484..51cdb68f 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -375,6 +375,7 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu command, cwd=workspace, env=environment, + capture_directory=workspace / "tmp", capture_output=True, check=False, text=True, diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 721b508e..d232c170 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -14,7 +14,7 @@ import unittest import zipfile from concurrent.futures import ThreadPoolExecutor -from dataclasses import FrozenInstanceError +from dataclasses import FrozenInstanceError, replace from datetime import datetime from pathlib import Path from unittest.mock import MagicMock, patch @@ -1802,7 +1802,7 @@ def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_s """Manifest snapshots hide credentials while collectors retain configured access.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - collector_id = "core.system.private_keys" + collector_id = "plugin.private_keys" settings = { "password": "password-value", "nested": {"refresh_token": "token-value", "cookie": "cookie-value"}, @@ -3073,7 +3073,16 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r encoding="utf-8", ) plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, load_config(root)).run(plan) + configuration = replace( + default_config(root), + collector_settings={ + "core.system.system_info": { + "password": "evidence-password", + "nested": {"access_token": "evidence-token"}, + } + }, + ) + outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual("succeeded", outcome.manifest.status.value) self.assertEqual("password=[REDACTED]", outcome.manifest.collectors[0].summary) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index d1d81a5d..cc68aed0 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -3,6 +3,7 @@ from __future__ import annotations import subprocess +import sys import tempfile import unittest from pathlib import Path @@ -50,6 +51,26 @@ def execute(command, *, stdout, stderr, **options): with self.assertRaisesRegex(ValueError, "capture limit"): adapter.run(["tool"], capture_output=True, text=True) + def test_process_adapter_can_capture_inside_an_isolated_workspace(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + capture_directory = Path(temporary) + result = ProcessAdapter().run( + [sys.executable, "-c", "print('workspace capture')"], + capture_directory=capture_directory, + capture_output=True, + check=False, + text=True, + ) + self.assertEqual(0, result.returncode) + self.assertEqual(["workspace capture"], result.stdout.splitlines()) + self.assertEqual("", result.stderr) + with self.assertRaisesRegex(ValueError, "existing directory"): + ProcessAdapter().run( + [sys.executable, "-c", "pass"], + capture_directory=capture_directory / "missing", + capture_output=True, + ) + def test_process_adapter_starts_shell_free_processes_and_reads_host_memory(self) -> None: process = Mock() with patch("logicytics.platform_adapters.subprocess.Popen", return_value=process) as popen: From fa1269a4bb69e8e6026dc73d53567a70f9a2d974 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 18:31:24 +0300 Subject: [PATCH 212/316] docs: finalize the Logicytics 4.0 release Publish the complete user, contributor, migration, security, feature-ownership, and release contracts for the recreated v4 collector surface, including accurate run-owned package locations and optional Windows capabilities. Promote package metadata from the alpha identifier to 4.0.0, modernize contribution templates, and tick every repository-backed TODO item supported by the full suite, strict preflight, compilation, and live Windows integration evidence. Leave only the external GitHub wiki synchronization item unticked because that content lives in a separate checkout and must be updated before the overall TODO can be closed. --- .github/ISSUE_TEMPLATE/bug_report.yml | 90 ++++---- .github/PULL_REQUEST_TEMPLATE.md | 60 ++--- CONTRIBUTING.md | 315 ++++++++++++++------------ FEATURE_STATUS.md | 70 ++++++ MIGRATION.md | 10 +- README.md | 153 +++++++++++-- SECURITY.md | 95 +++----- TODO.md | 14 +- V4_RELEASE.md | 134 +++++++++++ pyproject.toml | 2 +- tests/test_release_documentation.py | 132 +++++++++++ 11 files changed, 769 insertions(+), 306 deletions(-) create mode 100644 FEATURE_STATUS.md create mode 100644 V4_RELEASE.md create mode 100644 tests/test_release_documentation.py diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f813bca4..e7ab058a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,64 +1,78 @@ name: Report a bug -description: Tell us about a bug or issue you may have identified in Logicytics. -title: "Provide a general summary of the issue" -labels: [ "progress/Unreviewed" ] -assignees: "DefinetlyNotAI" +description: Report reproducible Logicytics v4 behavior without sharing private evidence. +title: "bug: " +labels: ["progress/Unreviewed"] +assignees: ["DefinetlyNotAI"] body: - type: checkboxes attributes: label: Prerequisites - description: Take a couple minutes to help our maintainers work faster. options: - - label: I have [searched](https://github.com/DefinetlyNotAI/Logicytics/issues?utf8=%E2%9C%93&q=is%3Aissue) for duplicate or closed issues. + - label: I searched existing issues and reproduced this on the latest supported v4 release. required: true - - label: I have read the [contributing guidelines](https://github.com/DefinetlyNotAI/Logicytics/blob/main/CONTRIBUTING.md). + - label: I read the contribution and security guidance and removed credentials, private evidence, and host identifiers. required: true - - label: I have checked that I am on the latest release, and have run the `--debug` flag and have made sure no external modification are responsible for this bug. + - label: I ran `python -m logicytics preflight` and the relevant command again with a sanitized configuration. required: true - type: textarea - id: what-happened + id: description attributes: - label: Describe the issue - description: Provide a summary of the issue and what you expected to happen, including specific steps to reproduce. + label: What happened? + description: Describe the actual result, expected result, and exact reproduction steps. + validations: + required: true + - type: input + id: version + attributes: + label: Logicytics version and commit + placeholder: "4.0.0, commit abc1234" + validations: + required: true + - type: input + id: environment + attributes: + label: Windows and Python versions + placeholder: "Windows 11 24H2; Python 3.11.9" + validations: + required: true + - type: dropdown + id: action + attributes: + label: Affected action + options: + - preflight or plan + - standard or balanced collection + - quick or thorough collection + - offline collection + - extensions or non-Python MODs + - performance collection + - direct collector execution + - debug, update, or developer action + - usage or semantic matching + - public Python API + - package, hash, or artifact reading + - other validations: required: true - type: textarea - id: d_log + id: command attributes: - label: Debugger Log - description: Include the log File generated by running `.\Logicytics --debug`, upload the File contents and paste it in. + label: Sanitized command and configuration + description: Include the command, profile/mode, approved capabilities, elevation state, and relevant non-secret settings. + render: powershell validations: required: true - type: textarea - id: b_log + id: diagnostics attributes: - label: Basic Log - description: If possible, delete the default log, and attempt to run `.\Logicytics` again with the steps you have highlighted, upload the new log here. + label: Redacted diagnostics + description: Paste only relevant redacted preflight, manifest status, collector failure, or debug details. Never attach collected evidence or secrets. validations: required: false - type: textarea id: extra attributes: - label: Anything else? - description: Include anything you deem important. + label: Additional context + description: Note whether optional WMIC, BitLocker, Sysinternals, PowerShell, or elevation capabilities were available. validations: required: false - - type: dropdown - id: flags_list - attributes: - label: What flags_list were you using to run Logicytics? - multiple: false - options: - - Threading - - Dev - - Default - - Modded - - Other - - N/A - - type: input - id: version - attributes: - label: What version of Logicytics are you using? - placeholder: "e.g., v2.1.0 or v1.4.0" - validations: - required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e62326c4..529f313b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,49 +1,31 @@ -## Pull Request Template +## Summary -### Prerequisites + - - +## Contract impact -- [ ] I have [searched](https://github.com/DefinetlyNotAI/Logicytics/pulls) for duplicate or closed issues. -- [ ] I have read the [contributing guidelines](https://github.com/DefinetlyNotAI/Logicytics/blob/main/CONTRIBUTING.md). -- [ ] I have followed the instructions in the [wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) about - contributions. -- [ ] I have updated the documentation accordingly, if required. -- [ ] I have tested my code with the `--dev` flag, if required. + -### PR Type +## Verification - - + -- [ ] Bug fix -- [ ] Deprecation Change -- [ ] New feature -- [ ] Refactoring -- [ ] Documentation - update -- [ ] ⚠️ Breaking change ⚠️ +- [ ] Relevant focused tests pass. +- [ ] `python -m unittest discover -v` passes. +- [ ] `python -m compileall -q logicytics core tests` passes. +- [ ] `python -m logicytics preflight` reports no invalid core collector. +- [ ] `git diff --check` passes. +- [ ] Live Windows integration tests pass when the change touches host behavior. -### Description +## Review checklist - +- [ ] I read [CONTRIBUTING.md](../CONTRIBUTING.md) and searched for duplicate work. +- [ ] This pull request contains one coherent theme and conventional commits. +- [ ] Collector metadata, cancellation, isolation, and artifact registration remain valid. +- [ ] Configuration, output, migration, release, and feature-status docs are updated when affected. +- [ ] No generated evidence, credentials, private data, caches, or unrelated changes are included. +- [ ] I agree to the [Developer Certificate of Origin](../DCO.md) and repository license. -### Motivation and Context +## Related issues - - -### Credit - - - - - -### Issues Fixed - - + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bd38ee12..b0605379 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,142 +1,177 @@ # Contributing to Logicytics -Before changing a legacy alias, configuration migration, CODE evidence import, -or MOD adapter, read [MIGRATION.md](MIGRATION.md). Compatibility bridges must -remain bounded translations into the canonical v4 request and artifact model; -do not add a second execution or output path. - -Each collector module owns exactly one collector class and one primary job. Split -a feature into a separate collector ID whenever it needs a different capability, -privilege level, network reach, sensitive-data category, timeout/cost profile, or -output name/format. Do not hide a second authorization or output contract behind -a setting in an existing collector. - -Configuration changes must preserve the strict schema described in -[CONFIGURATION.md](CONFIGURATION.md). Update that field reference and its -parser-backed documentation tests in the same commit as any setting, default, -bound, migration alias, or source-precedence change. Invocation-only behavior -belongs in `RunRequest` or CLI flags, not persistent configuration. - -Looking to contribute something to Logicytics? **Here's how you can help.** - -Please take a moment to review this document to make the contribution -process easy and effective for everyone involved. - -Following these guidelines helps to communicate that you respect the time of -the developers managing and developing this open source project. In return, -they should reciprocate that respect in addressing your issue or assessing -patches and features. - -## Using the issue tracker - -The [issue tracker](https://github.com/DefinetlyNotAI/Logicytics/issues) is -the preferred channel for bug reports and features requests -and submitting pull requests, but please respect the following -restrictions: - -- Please **Do not** derail or troll issues. Keep the discussion on topic and - respect the opinions of others. - -- Please **Do not** post comments consisting solely of "+1" or "👍 ". - Use [GitHub's "reactions" feature](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments) - instead. We reserve the right to delete comments which violate this rule. - -## Issues assignment - -I will be looking at the open issues, analyse them, and provide guidance on how to proceed. - -Issues can be assigned to anyone other than me and contributors are welcome -to participate in the discussion and provide their input on how to best solve the issue, -and even submit a PR if they want to. - -Please wait that the issue is ready to be worked on before submitting a PR. -We don't want to waste your time. - -Please keep in mind that I am a human and have limited resources and am not always able to respond immediately. -I will try to provide feedback as soon as possible, but please be patient. - -If you don't get a response immediately, -it doesn't mean that we are ignoring you or that we don't care about your issue or PR. -We will get back to you as soon as we can. - -If you decide to pull a PR or fork the project, keep in mind that you should only add/edit the scripts you need to, -leave core files alone. - -## Guidelines for Modifications 📃 - -When making modifications to the Logicytics project, -please adhere to the following guidelines on the Wiki page. - -## Issues and labels 🛠️ - -Our bug tracker utilizes several labels to help organize and identify issues. - -For a complete look at our labels, see the [project labels page](https://github.com/DefinetlyNotAI/Logicytics/labels). - -## Bug reports 🐛 - -A bug is a _demonstrable problem_ that is caused by the code in the repository. -Good bug reports are extremely helpful! - -Guidelines for bug reports: - -1. **Use the GitHub issue search** — check if the issue has already been - reported. - -2. **Check if the issue has been fixed** — try to reproduce it using the - latest `main` (or `version` branch if the issue is about a version) in the repository. - -A good bug report shouldn't leave others needing to chase you up for more -information. Please try to be as detailed as possible in your report. What is -your environment? What steps will reproduce the issue? What browser(s) and OS -experience the problem? Do other browsers show the bug differently? What -would you expect to be the outcome? All these details will help people to fix -any potential bugs. - -## Feature requests 🚀 - -Feature requests are welcome. But take a moment to find out whether your idea -fits with the scope and aims of the project. It's up to _you_ to make a strong -case to convince the project's developers of the merits of this feature. Please -provide as much detail and context as possible. - -## Pull requests 📝 - -Good pull requests—patches, improvements, new features—are a fantastic -help. They should remain focused in scope and avoid containing unrelated -commits. - -**Please ask first** before embarking on any **significant** pull request (e.g. -implementing features, refactoring code, porting to a different language), -otherwise you risk spending a lot of time working on something that the -project's developers might not want to merge into the project. For trivial -things, or things that don't require a lot of your time, you can go ahead and -make a PR. - -Please adhere to the coding guidelines used throughout the -project (indentation, accurate comments, etc.) and any other requirements -(such as test coverage). - -View the Wiki for more information on how to write pull requests. - -**IMPORTANT**: By submitting a patch, you agree to allow the project owners to -license your work under the terms of the [License](https://github.com/DefinetlyNotAI/Logicytics/blob/main/LICENSE) - -## License 📝 - -By contributing your code, you agree to license your contribution under -the [MIT License](https://github.com/DefinetlyNotAI/Logicytics/blob/main/LICENSE). -By contributing to the documentation, you agree to license your contribution under -the [Creative Commons Attribution 3.0 Unported License](https://creativecommons.org/licenses/by/3.0/). - -You also agree to the [Developer Certificate of Origin](DCO.md). - -## Communication 🗣️ - -- **Issues**: Use GitHub issues for bug reports and feature requests. Keep the discussion focused and relevant. -- **Pull Requests**: Use pull requests to propose changes. Be prepared to discuss your changes and address any feedback. - -If you have any questions or need further clarification, please feel free to [contact](mailto:Nirt_12023@outlook.com) -me. - -Thank you for your contributions! +Logicytics v4 is a Windows-focused evidence collector with strict authorization, +isolation, output, and compatibility contracts. Keep changes focused and preserve +those contracts. Use the [issue tracker](https://github.com/DefinetlyNotAI/Logicytics/issues) +for reproducible bugs and scoped feature proposals. + +## Development setup + +Requirements: + +- Windows for live collector and platform integration checks. +- Python 3.11 or later. The v4 engine has no third-party runtime dependency. +- Git for developer integrity and explicit update actions. + +From a fresh checkout: + +```powershell +python -m logicytics preflight +python -m unittest discover -v +python -m compileall -q logicytics core tests +``` + +`preflight` must report no invalid core collector. Some live Windows features such +as WMIC, BitLocker, or Sysinternals are optional; absence must produce an explicit +skip or availability result rather than breaking unrelated collection. + +## Architecture boundaries + +The canonical pipeline is: + +`request -> validated plan -> isolated collectors -> registered artifacts -> manifest -> package` + +- `logicytics/cli.py` parses and renders. It does not collect evidence. +- `logicytics/planner.py` resolves one deterministic plan from immutable + `RunRequest` policy and validated metadata. +- `logicytics/runtime.py` owns per-run and per-worker lifecycle, cancellation, + retries, timeouts, failure aggregation, and post-run actions. +- `logicytics/platform_adapters.py` owns host command, process, registry, + filesystem, network, privilege, and Win32 access. Collectors must use these + injectable seams instead of importing host APIs directly. +- `logicytics/artifacts.py` is the only publication path from collector + workspaces into the run artifact catalog. +- `logicytics/packaging.py` consumes the finalized catalog; it never scans source + directories for arbitrary files. +- Maintenance, debug, update, developer, and usage behavior stays separate from + normal collection. + +Importing `logicytics` must not start collection, load the supervisor, or create +files. Do not add global mutable run state, implicit current-directory behavior, +collector-to-collector calls, or a second execution/output path. + +## Core collector changes + +Each `core//.py` module owns exactly one public +`Collector` and one primary job. Split a feature into a new ID when +it needs a different capability, privilege level, network reach, sensitive-data +category, timeout/cost policy, or output contract. + +A collector must: + +- inherit `CoreCollector` and provide fully typed, documented `metadata`, + `validate`, `prepare`, `collect`, `finalize`, and `cleanup` behavior; +- use an ID, class name, file name, and `Specialty` that agree; +- declare platform, capabilities, privilege, network access, sensitivity, + profiles, dependencies, scheduling policy, timeouts, retries, memory/output + limits, artifact count, and every MIME type it can publish; +- check `context.is_cancelled` before work and during every long loop, query, + capture, copy, or traversal; +- write only beneath `context.workspace`, report structured progress, register + every result through `context.artifacts`, and return a typed result; +- turn expected absence or permission denial into an actionable `skipped` or + `partial` result without hiding an actual failure; +- avoid `print`, `exit`, nested worker pools, mutable globals, repository writes, + package-wide cleanup, secrets in logs, and unbounded reads or subprocess output. + +Run the collector directly and through the orchestrator. Add mocked Windows +responses, cancellation coverage, and output-contract evidence. If structured +bytes change intentionally, update the relevant golden file in `tests/golden/` +and explain why. + +## Plugins and MODs + +Plugins implement the typed `PluginCollector` contract and remain opt-in. MODs +use a sidecar declaration and may be Python, PowerShell, batch, or executable +payloads. Neither extension type may bypass preflight, planning, capability +approval, isolated workspaces, artifact registration, or package filtering. + +Read [MODS.md](MODS.md) before changing discovery or extension behavior. Read +[MIGRATION.md](MIGRATION.md) before changing a legacy flag, schema migration, +historical `CODE` evidence import, or MOD adapter. Compatibility code must remain +a bounded translation into the canonical v4 model. + +## Configuration changes + +Configuration changes must preserve the strict schema in +[CONFIGURATION.md](CONFIGURATION.md). Update the field reference and its +parser-backed tests in the same commit as any setting, default, bound, migration +alias, or source-precedence change. + +Profiles, modes, include/exclude selections, plugin/MOD enablement, capability +approval, authorization acknowledgement, scheduling overrides, reruns, package +policy, and post-run power actions are invocation-only `RunRequest` behavior. +They do not belong in persistent configuration. + +## Evidence, security, and compatibility + +- Update [OUTPUTS.md](OUTPUTS.md) when a filename, MIME type, package path, + evidence kind, or retention rule changes. +- Keep source, executables, models, configuration secrets, caches, and library + internals out of evidence packages. +- Preserve reproducible package hashes and manifest schema validation. +- Add explicit capabilities for sensitive or elevated access. Never weaken + authorization to make a test pass. +- Follow [SECURITY.md](SECURITY.md) for vulnerability reports. Do not put secrets + or real private evidence in issues, fixtures, logs, or commits. + +## Testing expectations + +Use the narrowest relevant tests while iterating, then run the complete gates +before opening a pull request: + +```powershell +python -m unittest discover -v +python -m compileall -q logicytics core tests +python -m logicytics preflight +git diff --check +``` + +On Windows, also run: + +```powershell +python -m unittest tests.test_windows_integration -v +``` + +The suite includes static preflight, lifecycle/cancellation checks, mocked +collector responses, golden output bytes, flow/mode matrices, package/hash +reproduction, documentation contracts, and bounded live Windows probes. A build +or compile check alone is not sufficient evidence. + +## Documentation changes + +Keep user and developer documentation synchronized with behavior: + +- [README.md](README.md): installation, quick start, CLI, permissions, and + troubleshooting. +- [CONFIGURATION.md](CONFIGURATION.md): every persistent setting and migration. +- [OUTPUTS.md](OUTPUTS.md): artifact and retention contracts. +- [MODS.md](MODS.md): extension contract. +- [MIGRATION.md](MIGRATION.md): supported compatibility boundary. +- [FLOW_MATRIX.md](FLOW_MATRIX.md): executable flow evidence. +- [FEATURE_STATUS.md](FEATURE_STATUS.md): TODO ownership and completion evidence. +- [V4_RELEASE.md](V4_RELEASE.md): v4 recreation scope and release verification. + +The repository wiki is complementary documentation, not a substitute for the +versioned contract files required to review a change. + +## Issues and pull requests + +A useful bug report includes the Logicytics version, Windows edition/build, +Python version, command and sanitized configuration, expected and actual result, +exit code, relevant redacted logs, and exact reproduction steps. State whether +the process was elevated and whether an optional Windows feature was installed. + +Pull requests should: + +- solve one coherent problem and avoid unrelated edits; +- use conventional commit subjects such as `feat:`, `fix:`, `refactor:`, + `test:`, or `docs:` with a detailed body; +- include tests and documentation proportional to the changed contract; +- preserve unrelated work and never include generated evidence or secrets; +- pass the complete verification gates above; and +- comply with the [Developer Certificate of Origin](DCO.md), + [Code of Conduct](CODE_OF_CONDUCT.md), and repository license. + +By contributing code, you agree to license it under the [MIT License](LICENSE). +Documentation contributions use the repository's stated documentation license. diff --git a/FEATURE_STATUS.md b/FEATURE_STATUS.md new file mode 100644 index 00000000..336a7b21 --- /dev/null +++ b/FEATURE_STATUS.md @@ -0,0 +1,70 @@ +# v4 feature ownership and status + +This register maps every section in [TODO.md](TODO.md) to its implementation +owner, current status, and primary evidence. Every checklist item inherits the +owner of its nearest heading; the checkbox beside that item is the authoritative +item-level status. `Wiki pending` means repository code and versioned docs are +complete but the separate GitHub wiki checkout still needs synchronization. + +| TODO section | Owner | Status | Primary evidence | +| --- | --- | --- | --- | +| Core product redesign — mandatory v4.0 change | Architecture maintainers | Complete | `logicytics/contracts.py`, `planner.py`, `runtime.py`, `packaging.py` | +| Core functionality status | Runtime maintainers | Complete | `tests/test_core_functionality.py`, `tests/test_flow_matrix.py` | +| Core and plugin layout — canonical v4.0 model | Discovery maintainers | Complete | `core/`, `plugins/`, `logicytics/discovery.py` | +| Strict collector file, class, and specialty standards | Collector contract maintainers | Complete | `logicytics/contracts.py`, `discovery.py`, `tests/test_shipped_collectors.py` | +| File and folder rules | Discovery maintainers | Complete | `logicytics/discovery.py`, static preflight tests | +| Class and identity rules | Collector contract maintainers | Complete | `CollectorMetadata`, strict preflight tests | +| Specialty rules | Collector contract maintainers | Complete | `Specialty`, shipped collector catalog tests | +| Required class interface | Collector contract maintainers | Complete | `CoreCollector`, `PluginCollector`, lifecycle tests | +| Static and runtime preflight validation | Discovery maintainers | Complete | `logicytics/discovery.py`, `validation_worker.py` | +| Per-collector sandbox and failure isolation | Runtime maintainers | Complete | `logicytics/runtime.py`, cancellation/isolation tests | +| Replace the current architectural model | Architecture maintainers | Complete | v4 packages and `MIGRATION.md` | +| Define the v4.0 run model | Runtime and manifest maintainers | Complete | `runtime.py`, `manifest.py`, package integration tests | +| Separate responsibilities into layers | Architecture maintainers | Complete | CLI, planner, runtime, adapters, artifacts, packaging, maintenance modules | +| Make collection safe, bounded, and observable | Runtime and security maintainers | Complete | capability metadata, worker limits, progress/failure tests | +| Make concurrency a planned capability | Planner/runtime maintainers | Complete | resource scheduler and concurrency tests | +| Make configuration and profiles understandable | Configuration/planner maintainers | Complete | `CONFIGURATION.md`, `configuration.py`, profile tests | +| Improve the extension model | Extension maintainers | Complete | `MODS.md`, plugin/MOD discovery and isolation tests | +| Improve packaging and evidence integrity | Artifact/packaging maintainers | Complete | `OUTPUTS.md`, package/hash reproduction tests | +| Improve the public API and testability | API maintainers | Complete | `logicytics/api.py`, public API tests | +| Migration rules from the old design | Compatibility maintainers | Complete | `MIGRATION.md`, migration regression tests | +| v4.0 goals | Release maintainers | Complete | `V4_RELEASE.md` and full release gates | +| 1. Main engine and execution lifecycle | Runtime maintainers | Complete | CLI/planner/runtime integration tests | +| Execution modes | Mode/planner maintainers | Complete | `logicytics/modes.py`, machine-readable mode matrix tests | +| Post-run actions | Runtime maintainers | Complete | typed shutdown/reboot package-gating tests | +| 2. Command-line and interaction features | CLI/interaction maintainers | Complete | `cli.py`, `interaction.py`, usage/matching tests | +| 3. Core Logicytics engine/library API | API maintainers | Complete | public lazy API and integration tests | +| Command execution | Platform adapter maintainers | Complete | `ProcessAdapter`, command/MOD tests | +| File discovery | Discovery maintainers | Complete | `file_listing.py`, recursive discovery tests | +| Environment checks | Platform adapter maintainers | Complete | `environment.py`, adapter and live Windows tests | +| Configuration | Configuration maintainers | Complete | `CONFIGURATION.md`, parser/migration tests | +| Logging | Logging maintainers | Complete | `logging.py`, singleton/redaction/retention tests | +| Output and packaging | Artifact/packaging maintainers | Complete | `artifacts.py`, `packaging.py`, `OUTPUTS.md` | +| 4. Standard system-data collectors | Core collector maintainers | Complete | 66-collector preflight, lifecycle, output, and Windows tests | +| System, hardware, and operating-system inventory | System/hardware collector maintainers | Complete | `core/system/`, `core/hardware/` | +| Network inventory and activity | Network/packet collector maintainers | Complete | `core/network/`, `core/packet/` | +| Wireless, Bluetooth, and removable-device history | Wireless/device collector maintainers | Complete | `core/wireless/`, `core/bluetooth/`, `core/usb/` | +| Windows logs, registry, storage, and encryption | Windows evidence collector maintainers | Complete | `core/event_log/`, `registry/`, `storage/`, `encryption/` | +| Memory and process diagnostics | Process/memory collector maintainers | Complete | `core/process/`, `core/memory/` | +| User files and media | File evidence collector maintainers | Complete | `core/browser/`, `filesystem/`, `media/`, `ssh/` | +| Sysinternals integration | Diagnostics collector maintainers | Complete | `core/diagnostics/sysinternals_report.py`, `sysinternals.py` | +| 5. Maintenance and developer features | Maintenance maintainers | Complete | `maintenance.py`, CLI maintenance flow tests | +| Debugger (`--debug`) | Maintenance maintainers | Complete | debug diagnostics tests and `FLOW_MATRIX.md` | +| Update action (`--update`) | Maintenance maintainers | Complete | authenticated/read-only check and explicit apply tests | +| Developer action (`--dev`) | Maintenance maintainers | Complete | integrity/version/developer check tests | +| 6. MODS extension system | Extension maintainers | Complete | `MODS.md`, MOD runner and package tests | +| 7. Configuration and output contract | Configuration/artifact maintainers | Complete | `CONFIGURATION.md`, `OUTPUTS.md`, contract tests | +| 8. v4.0 delivery phases | Release maintainers | Wiki pending | phases 1-4 complete; phase 5 awaits separate wiki checkout | +| Phase 1 — Foundation | Architecture maintainers | Complete | contracts, configuration, logging, adapters, run workspace | +| Phase 2 — Engine and packaging | Runtime/packaging maintainers | Complete | scheduling, modes, ZIP/hash, MOD packaging tests | +| Phase 3 — Core collectors | Core collector maintainers | Complete | all 66 core collectors pass strict preflight | +| Phase 4 — Deep collection and visualization | Deep-collection maintainers | Complete | deep profile, packet graph, usage SVG tests | +| Phase 5 — Maintenance and release readiness | Release maintainers | Wiki pending | repository release gates complete; external wiki synchronization pending | +| 9. v4.0 completion checklist | Release maintainers | Wiki pending | repository checklist complete except the separate wiki update | + +## Maintenance rule + +Any new TODO heading must be added to this register in the same commit. Any item +whose owner, status, or evidence changes must update this table and the relevant +versioned contract document. The documentation contract test enforces exact +heading coverage so features cannot become ownerless. diff --git a/MIGRATION.md b/MIGRATION.md index 5f7c0c4f..6cf023ba 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -31,10 +31,12 @@ fail closed. Migration provenance is recorded in the run manifest. ## Output migration There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/` compatibility -copies. Their v4 replacements are respectively the run tree under -`output/data/run-/`, `output/logs/`, `output/data/zip/`, and -`output/data/hashes/`. The run artifact tree and verified package layout in -[OUTPUTS.md](OUTPUTS.md) are canonical. +copies. Their v4 replacements are the run tree under `output/data/run-/`, +application diagnostics under `output/logs/`, and each run's `packages/` and +`hashes/` directories. `output/data/zip/` and `output/data/hashes/` are created +as stable compatibility landing directories for external tooling but are not +populated with duplicate evidence. The run artifact tree and verified package +layout in [OUTPUTS.md](OUTPUTS.md) are canonical. The legacy CODE evidence collector preserves a historical file's relative name only inside its collector-owned `legacy_code/` subtree. This is the sole legacy diff --git a/README.md b/README.md index 32362d98..207ef6c3 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # Logicytics v4 -Logicytics v4 is a Windows-focused, run-oriented evidence collection framework. -It is being rebuilt from a clean architecture around an explicit pipeline: +Logicytics v4.0 is a complete recreation of the Windows system-data collection +and evidence-packaging tool around an explicit run-oriented pipeline: `request -> validated plan -> isolated collectors -> registered artifacts -> manifest -> package` -The project is currently an early v4 implementation. The engine and the first -safe system collector are available; the broader collection catalog is being -rebuilt incrementally. +The release ships 66 independently runnable core collectors, typed profiles and +modes, opt-in plugins and MODs, isolated workers, reproducible manifests and +packages, and bounded Windows integrations. See [V4_RELEASE.md](V4_RELEASE.md) +for the release scope and [FEATURE_STATUS.md](FEATURE_STATUS.md) for ownership +and verification evidence. ## Current capabilities @@ -20,7 +22,10 @@ rebuilt incrementally. and source-copy loops, with unpublished sensitive bytes removed. - Artifact registration with workspace boundaries, output limits, SHA-256 hashes, a run manifest, ZIP package, and package hash. -- Shipped collectors: `core.system.system_info` and the capability-gated +- Sixty-six shipped collectors cover all v4 specialties. The authoritative + collector catalog and exact mode membership are emitted by + `python -m logicytics --modes`; representative collectors include + `core.system.system_info` and the capability-gated `core.process.running_processes` and `core.network.network_identity`, plus `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile `core.hardware.windows_features`, `core.network.network_adapters`, and @@ -55,7 +60,9 @@ rebuilt incrementally. `core.storage.physical_disks`, and `core.storage.mounted_volumes`. `core.storage.volume_details` adds filesystem and label metadata in the deep profile. `core.encryption.bitlocker_status` is also available in the deep profile. - `core.encryption.bitlocker_volumes` provides the PowerShell volume view. + `core.encryption.bitlocker_volumes` provides the PowerShell volume view, while + `core.system.wmic_inventory` preserves an optional bounded WMIC view when that + Windows capability is installed. ## Layout @@ -72,8 +79,8 @@ output/ # ignored generated evidence and diagnostics debug/ # debug-action diagnostics performance/ # stable performance-report location data/ - zip/ # stable package-output location - hashes/ # stable package-hash location + zip/ # created compatibility landing directory; canonical packages are run-owned + hashes/ # created compatibility landing directory; canonical hashes are run-owned run-/ # one self-contained run output tree artifacts/ # collector-owned registered evidence store collectors/ # private collector workspaces and event channels @@ -108,10 +115,10 @@ action, cancellation, and permission flow is indexed in [FLOW_MATRIX.md](FLOW_MA ## Requirements - Python 3.11 or later. -- Windows for the currently shipped collector and future Windows integrations. +- Windows for core collection and live platform integration checks. -The v4 core uses only the Python standard library. A collector may later declare -its own dependency and capability requirements. +The v4 core uses only the Python standard library. Extension collectors declare +their own dependency and capability requirements. Collectors reach Windows through `logicytics.platform_adapters`: guarded process execution covers PowerShell, WMI/CIM, WMIC, and command tools; dedicated adapters @@ -127,7 +134,37 @@ workers share the same event-logger factory while receiving distinct run-owned JSONL channels. Logger objects are intentionally not shared across process boundaries, preserving collector isolation and avoiding cross-process mutable state. -## Commands +## Installation + +Clone the repository on Windows and use Python 3.11 or later. No third-party +runtime package is required: + +```powershell +git clone https://github.com/DefinetlyNotAI/Logicytics.git +cd Logicytics +python -m logicytics preflight +``` + +`preflight` is read-only. It validates configuration, host prerequisites, +collector contracts, optional Windows features, and quarantine state before a +run. A missing optional WMIC, BitLocker, or Sysinternals capability is reported +without invalidating unrelated collectors. + +## Quick start + +Inspect the exact plan first, then acknowledge authorization explicitly: + +```powershell +python -m logicytics plan --profile standard +python -m logicytics run --profile standard --acknowledge-authorization +``` + +The standard profile is intentionally bounded. Use `--mode thorough` for the full +deep catalog and approve every requested capability deliberately. Generated run +data appears under `output/data/run-/`; the final summary prints collector +status, duration, failures, package path, and package hash. + +## CLI reference Run these from the repository root: @@ -136,6 +173,9 @@ python -m logicytics preflight python -m logicytics debug python -m logicytics dev python -m logicytics update --launch-action debug --new-window +python -m logicytics --match "collect quickly" +python -m logicytics usage +python -m logicytics --modes python -m logicytics collector core.system.system_info --acknowledge-authorization python -m logicytics plan --profile standard python -m logicytics run --profile standard --acknowledge-authorization @@ -177,6 +217,50 @@ python -m logicytics run --profile standard --acknowledge-authorization ` --allow-capability filesystem_read ``` +Important global and run controls: + +| Control | Purpose | +| --- | --- | +| `--config PATH` | Load a schema-v4 JSON or supported legacy INI configuration. | +| `--match TEXT` | Suggest the closest documented action; history persists only when configured. | +| `--modes` | Print the versioned collector/mode matrix. | +| `run --mode NAME` | Select one canonical execution mode. | +| `--include ID` / `--exclude ID` | Override profile membership by exact collector ID. | +| `--plugins` / `--mods` | Opt into discovered plugins or declared MODs. | +| `--workers COUNT` | Override bounded concurrency within the configured maximum. | +| `--sequential` / `--parallel` | Select explicit scheduling without a legacy mode alias. | +| `--rerun-from MANIFEST` | Rerun selected collectors while preserving parent-run provenance. | +| `--no-package` | Retain the manifest/run tree without a ZIP or ZIP hash. | +| `--reboot` / `--shutdown` | Schedule one mutually exclusive power action only after verified packaging. | +| `update --apply` | Explicitly apply the configured Git update; an ordinary update check is read-only. | + +Legacy `--default`, `--threaded`, `--minimal`, `--depth`, `--modded`, `--nopy`, +and `--performance-check` flags remain exact aliases. They cannot be combined +with a contradictory mode, profile, or scheduling override. + +## Authorization, permissions, and status + +Collection requires `--acknowledge-authorization`. Capabilities such as +filesystem reads, registry reads, subprocesses, browser data, private keys, +sensitive files, packet capture, network access, or elevation must also be +approved explicitly when selected metadata requests them. An elevated collector +is rejected during planning unless the process is actually administrative. + +Collector outcomes are `succeeded`, `partial`, `skipped`, `cancelled`, or +`failed`. Expected absence and access denial are visible skips when no evidence +can be collected; unexpected command, parsing, timeout, output-limit, or worker +failures remain failures. One collector failure does not stop independent peers, +and the run cannot appear fully successful while a selected collector failed. + +Ctrl+C requests cooperative cancellation. Long collectors check the run-owned +cancellation marker, the supervisor terminates unresponsive worker trees within +their timeout policy, and a recoverable partial manifest/package is finalized +when possible. Cleanup removes only worker-owned temporary data. + +CLI exit status is `0` for the requested successful/read-only action, `1` for an +unsuccessful collection or maintenance result, and `2` for invalid arguments, +configuration, preflight, authorization, or other Logicytics contract errors. + ## Collection profiles - `minimal`: only collectors that explicitly declare essential local inventory. @@ -376,7 +460,8 @@ classification across the isolated worker boundary. Malformed core collectors block a run. Malformed unselected plugins are quarantined and listed by preflight; malformed explicitly selected plugins block -the requested run. See [TODO.md](TODO.md) for the complete v4 recreation plan. +the requested run. See [FEATURE_STATUS.md](FEATURE_STATUS.md) for implemented +ownership and [V4_RELEASE.md](V4_RELEASE.md) for the released feature surface. ## Verification @@ -385,6 +470,46 @@ python -m unittest discover -v python -m compileall -q logicytics core tests ``` +On Windows, `python -m unittest tests.test_windows_integration -v` adds bounded +live privilege, registry, PowerShell, CIM/WMI, optional WMIC, event-log, +BitLocker, networking, Sysinternals, and artifact-publication probes. + +## Troubleshooting + +- **A collector is skipped:** inspect its manifest `summary`, `errors`, and + `failure` fields. Install the named optional Windows feature, run from an + authorized elevated console when required, or approve the exact capability. +- **Planning rejects capabilities:** rerun `plan` first and add only the listed + `--allow-capability` values. Sensitive and elevated access is never inferred. +- **PowerShell execution policy is unavailable:** run `preflight`; confirm + `powershell` is on `PATH`. The engine does not weaken machine policy. +- **WMIC is unavailable:** modern Windows may omit the optional WMIC feature. + `core.system.wmic_inventory` skips explicitly; CIM/WMI collectors continue. +- **A plugin is quarantined:** use `preflight` to read its static/runtime errors. + Unselected invalid plugins do not block core collection. +- **A run was interrupted:** inspect the printed run manifest under + `output/data/run-/metadata/`; cancellation preserves recoverable partial + evidence and removes only private worker scratch data. +- **A package hash does not match:** do not trust or open the package. Use the + adjacent `.sha256` sidecar and rerun collection; package verification fails + closed on modified bytes. +- **Output is unexpectedly large:** lower collector-specific bounds in + [CONFIGURATION.md](CONFIGURATION.md) or use `minimal`/`standard`; the aggregate + run budget prevents unbounded publication. + +## Documentation + +- [CONFIGURATION.md](CONFIGURATION.md) — complete persistent settings contract. +- [OUTPUTS.md](OUTPUTS.md) — every collector path, format, package path, and + retention rule. +- [MODS.md](MODS.md) — opt-in MOD extension contract. +- [MIGRATION.md](MIGRATION.md) — supported v3 and legacy compatibility boundary. +- [FLOW_MATRIX.md](FLOW_MATRIX.md) — executable evidence for every user flow. +- [FEATURE_STATUS.md](FEATURE_STATUS.md) — TODO section owners and status. +- [V4_RELEASE.md](V4_RELEASE.md) — final v4.0 recreation and release evidence. +- [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and + [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) — contributor and security policy. + ## Authorization Use Logicytics only on systems and data you are authorized to inspect. The v4 diff --git a/SECURITY.md b/SECURITY.md index c66701d3..70f6b254 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,74 +1,43 @@ -# Security Policy +# Security policy -## Supported Versions +## Supported versions -This section outlines the versions of our project that are currently supported with security updates. +| Version | Security support | Release date | +| --- | --- | --- | +| 4.0.x | Supported | 2026-08-31 | +| 3.x and earlier | Unsupported | — | -| Version | Supported | Major Release Date | -|---------|-----------|--------------------| -| 4.0.x | ✅ | To be released | -| 3.6.x | ✖️ | July 26, 2025 | -| 3.5.x | ✖️ | July 26, 2025 | -| 3.4.x | ❌ | January 3, 2025 | -| 3.3.x | ❌ | January 3, 2025 | -| 3.2.x | ❌ | Dec 19, 2024 | -| 3.1.x | ❌ | Dec 11, 2024 | -| 3.0.x | ❌ | Dec 6, 2024 | -| 2.5.x | ❌ | Nov 25, 2024 | -| 2.4.x | ❌ | Nov 12, 2024 | -| 2.3.x | ❌ | Sep 21, 2024 | -| 2.2.x | ❌ | Sep 9, 2024 | -| 2.1.x | ❌ | Aug 29, 2024 | -| 2.0.x | ❌ | Aug 25, 2024 | -| 1.6.x | ❌ | Jun 18, 2024 | -| 1.5.x | ❌ | Jun 10, 2024 | -| 1.4.x | ❌ | May 30, 2024 | -| 1.3.x | ❌ | May 21, 2024 | -| 1.2.x | ❌ | May 16, 2024 | -| 1.1.x | ❌ | May 10, 2024 | -| 1.0.x | ❌ | May 4, 2024 | +Only the current v4 release line receives security fixes. Upgrade through the +documented [migration boundary](MIGRATION.md); do not run an unsupported checkout +against sensitive evidence. -### Key: +## Reporting a vulnerability -| Key | Desc | -|-----|-----------------------------------------------------| -| ✅ | Supported for all security updates | -| ✖️ | Only for major security issues (CVSS 8.0+) | -| ❌ | No longer supported for any security updates | +Do not open a public issue containing an exploit, credential, private key, +collected artifact, host identifier, or unredacted log. Email +[Nirt_12023@outlook.com](mailto:Nirt_12023@outlook.com) with the subject +`Logicytics security vulnerability`. -## Reporting a Vulnerability +Include: -If you believe you have found a security vulnerability in our project, we encourage you to report it to us. Your report -will help us improve the security of our project and ensure the trust of our users. +- the affected Logicytics version and commit; +- Windows edition/build and Python version; +- the affected collector, command, capability, or package contract; +- reproducible steps and expected security boundary; +- impact and required authorization/elevation state; and +- a minimal redacted proof of concept. -### How to Report a Vulnerability +Reports should target behavior owned by this repository and be reproducible. +Maintainers will acknowledge receipt, investigate the report, coordinate a fix +and disclosure window when accepted, and credit the reporter if requested. -1. **Identify the Vulnerability**: Clearly describe the vulnerability, including how it can be exploited and any - potential impact. -2. **Provide Detailed Information**: Include as much detail as possible, such as the version of the project affected, - steps to reproduce the vulnerability, and any relevant code snippets or screenshots. -3. **Contact Us**: Send your report to my [email](mailto:Nirt_12023@outlook.com). Please include "Security Vulnerability - Report" in the subject line. +## Security boundaries -### What to Expect +Logicytics requires authorization acknowledgement and explicit capability +approval. A vulnerability includes bypassing those checks, escaping a collector +workspace, publishing undeclared evidence, leaking secrets to logs/metadata, +executing an undeclared process or network action, corrupting package/hash +verification, or allowing one collector to affect unrelated runs. -- **Acknowledgment**: Upon receiving your report, we will acknowledge receipt within 2�5 business days. -- **Investigation**: Our security team will investigate the vulnerability and determine its validity. -- **Update**: If the vulnerability is accepted, we will work on a fix and provide an update on the timeline for a - security update. -- **Communication**: We will communicate with you regarding the status of the vulnerability and any necessary actions. - -### Vulnerability Acceptance Criteria - -- The vulnerability must be reproducible. -- The vulnerability must be exploitable. -- The vulnerability must not be a false positive. - -### Vulnerability Decline Criteria - -- The vulnerability is outside the scope of our project. - -Thank you for helping us maintain the security of our project. Your contribution is invaluable in keeping our users -safe. - ---- +Do not weaken isolation, redaction, path validation, output limits, cancellation, +or package verification while developing a fix. Use synthetic fixtures only. diff --git a/TODO.md b/TODO.md index 3a9cf8c7..092769fc 100644 --- a/TODO.md +++ b/TODO.md @@ -540,18 +540,18 @@ Windows platform allows. ## v4.0 goals -- [ ] Recreate Logicytics as a Windows system-data collection and packaging tool. -- [ ] Preserve every supported collection category, execution mode, output type, +- [x] Recreate Logicytics as a Windows system-data collection and packaging tool. +- [x] Preserve every supported collection category, execution mode, output type, configuration option, and extension point listed below. - [x] Keep collection modules independently runnable as well as runnable through the main orchestrator. -- [ ] Make collection results predictable, timestamped, inspectable, and easy to +- [x] Make collection results predictable, timestamped, inspectable, and easy to verify after export. -- [ ] Keep administrator checks, permission-aware collection, cancellation, and +- [x] Keep administrator checks, permission-aware collection, cancellation, and failure reporting as first-class behavior. - [x] Keep the default run reasonably quick while retaining a deeper, slower mode for exhaustive collection. -- [ ] Keep the project Windows-focused, including PowerShell, registry, WMI, +- [x] Keep the project Windows-focused, including PowerShell, registry, WMI, WMIC, Windows event logs, BitLocker, and Sysinternals integrations. ## 1. Main engine and execution lifecycle @@ -928,7 +928,7 @@ appropriate default/deep/non-Python mode. ## 9. v4.0 completion checklist -- [ ] Every feature in this file has an implementation owner/status. +- [x] Every feature in this file has an implementation owner/status. - [x] Every collector can be run directly and through the orchestrator. - [x] Every mode has an explicit collector inclusion matrix. - [x] Every output has a documented path, format, and retention rule. @@ -937,7 +937,7 @@ appropriate default/deep/non-Python mode. own temporary data. - [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. - [x] Debug/update/developer tools are separated from normal collection behavior. -- [ ] The final v4.0 release is documented as a complete recreation of the feature +- [x] The final v4.0 release is documented as a complete recreation of the feature surface listed here. Once all is complete, ask the user to git clone the wiki of logicytics so that we may update the DOCS completly diff --git a/V4_RELEASE.md b/V4_RELEASE.md new file mode 100644 index 00000000..85fce429 --- /dev/null +++ b/V4_RELEASE.md @@ -0,0 +1,134 @@ +# Logicytics 4.0.0 release + +Release date: 2026-08-31 + +Logicytics 4.0.0 is the complete recreation of the feature surface defined in +[TODO.md](TODO.md). It preserves the supported Windows system-data collection, +interaction, maintenance, extension, output, and packaging capabilities while +replacing the historical shared-script design with a typed, run-oriented, +isolated architecture. + +The repository implementation and versioned documentation are release-complete. +Synchronization of the separate GitHub wiki checkout is tracked independently in +the TODO because the wiki is not stored in this repository. + +## Release identity + +- Package version: `4.0.0`. +- Collector contract: `4.0`. +- Configuration schema: `4` with bounded schema-3 JSON and `CODE/config.ini` + migration. +- Run manifest schema: `1`. +- Package layout: `1.0`. +- Runtime: Python 3.11 or later on Windows; standard library only for the core. + +No v3 engine path remains. Supported legacy inputs are translations into the v4 +request, collector, artifact, and manifest contracts described in +[MIGRATION.md](MIGRATION.md). + +## Preserved feature surface + +The shipped catalog contains 66 independently runnable core collectors. Their +collection and reporting surface covers `system`, `hardware`, `process`, +`memory`, `filesystem`, `network`, `packet`, `wireless`, `bluetooth`, `usb`, +`browser`, `registry`, `event_log`, `storage`, `encryption`, `media`, `ssh`, +`diagnostics`, `reporting`, and `integration`. Reporting is owned by the run +manifest, summaries, performance results, interaction statistics, and package +catalog rather than a standalone evidence collector. + +Windows access includes bounded PowerShell, CIM/WMI, optional WMIC, registry, +event-log, BitLocker, network, filesystem, process, Win32 privilege, and optional +Sysinternals integrations behind mockable platform adapters. Optional host +features produce explicit availability or skip results and never stop unrelated +collectors. + +The canonical profiles are `minimal`, `standard`, `deep`, and `offline`. The +canonical modes are `standard`, `balanced`, `quick`, `thorough`, `offline`, +`extensions`, `non-python`, and `performance`; all historical mode flags remain +validated aliases. The machine-readable `python -m logicytics --modes` output is +the authoritative collector inclusion matrix. + +Core evidence contracts include `application/json`, `application/octet-stream`, +`application/xml`, `application/zip`, `text/csv`, `text/html`, `text/plain`, and +`text/vnd.graphviz`. [OUTPUTS.md](OUTPUTS.md) defines every stable workspace +pattern, package path, format, evidence kind, and retained-with-run policy. + +Persistent settings cover runtime output/concurrency/package limits, local +interaction/history policy, authenticated maintenance manifests, application +logging, and bounded collector-specific fields. [CONFIGURATION.md](CONFIGURATION.md) +is the complete parser-backed schema reference. Profiles, explicit selections, +extensions, capabilities, authorization, scheduling, reruns, output policy, and +power actions remain invocation-only. + +Plugins and MODs remain opt-in extension points. Python, PowerShell, batch, and +executable MOD payloads require sidecars and use the same preflight, planning, +capability, isolation, artifact, failure, and packaging boundaries as core +collectors. Historical generated `CODE` evidence has one bounded compatibility +collector; source and executable material is excluded. + +## Predictable and inspectable results + +Each request resolves to a deterministic collector/dependency order and plan +fingerprint before collection. Every selected collector receives a private +process, workspace, event channel, cancellation marker, configuration snapshot, +and artifact writer. Terminal states are explicit: `succeeded`, `partial`, +`skipped`, `cancelled`, or `failed`. + +Every run owns a UTC/request/run-ID package name, redacted manifest, human +summary, structured logs, artifact checksum catalog, verified ZIP, and external +ZIP SHA-256 sidecar under `output/data/run-/`. A rerun records its parent and +uses a separate package identity. Manifest-only output is explicit. Finished +evidence remains with its run until the user removes that run; worker scratch +data is removed only after durable publication. + +Artifact publication is workspace-confined, streamed where possible, size/file +bounded, MIME-checked, and SHA-256 cataloged. Packages contain registered evidence +and generated metadata only. Readers validate schema, run ownership, paths, +sizes, and hashes before returning or opening evidence. + +## Authorization, failure, and cancellation + +Collection cannot start without explicit authorization acknowledgement. +Sensitive, network, packet, browser, private-key, filesystem, subprocess, +registry, and elevation capabilities are declared per collector and require +approval. Elevated selection also requires a live administrator check. + +One collector failure does not abort independent work, but its failure cannot be +hidden by a successful package. The manifest records operation, platform error, +remediation, retry safety, lifecycle timestamps, progress, worker exit, and +termination reason. Permission denial and optional feature absence remain visible +typed outcomes. + +Long traversals, copies, queries, captures, and enumerations check cooperative +cancellation. The supervisor also enforces time, memory, output, artifact-count, +heartbeat, and process-tree boundaries. Cancelled runs finalize recoverable +partial evidence when possible and clean only their own temporary state. + +## Verification evidence + +The v4 release gates are: + +```powershell +python -m unittest discover -v +python -m compileall -q logicytics core tests +python -m logicytics preflight +python -m unittest tests.test_windows_integration -v +git diff --check +``` + +The suite statically and dynamically validates all 66 core collectors, direct and +orchestrated execution, lifecycle/cancellation behavior, mocked Windows failures, +live Windows boundaries, stable outputs, golden structured bytes, profiles and +modes, extension isolation, API reads, configuration migration, run/package/hash +integrity, and the complete flow matrix in [FLOW_MATRIX.md](FLOW_MATRIX.md). + +[FEATURE_STATUS.md](FEATURE_STATUS.md) assigns every TODO section an owner, +status, and implementation/test evidence. The TODO checkboxes remain the +authoritative item-level completion record. + +## Upgrade and support + +Read [MIGRATION.md](MIGRATION.md) before reusing a v3 configuration, legacy flag, +MOD, or generated `CODE` output. Read [README.md](README.md) for installation and +operation, [CONTRIBUTING.md](CONTRIBUTING.md) for change requirements, and +[SECURITY.md](SECURITY.md) for supported versions and vulnerability reporting. diff --git a/pyproject.toml b/pyproject.toml index 45c7eb13..3e3678af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "logicytics" -version = "4.0.0a1" +version = "4.0.0" description = "A run-oriented Windows evidence collection framework." requires-python = ">=3.11" dependencies = [] diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py new file mode 100644 index 00000000..8c22c61b --- /dev/null +++ b/tests/test_release_documentation.py @@ -0,0 +1,132 @@ +"""Executable release-documentation and feature-ownership contracts for v4.0.""" + +from __future__ import annotations + +import re +import tomllib +import unittest +from pathlib import Path + +from logicytics.contracts import Specialty +from logicytics.discovery import preflight +from logicytics.modes import EXECUTION_MODES + + +class ReleaseDocumentationTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.root = Path(__file__).resolve().parent.parent + cls.readme = (cls.root / "README.md").read_text(encoding="utf-8") + cls.release = (cls.root / "V4_RELEASE.md").read_text(encoding="utf-8") + cls.status = (cls.root / "FEATURE_STATUS.md").read_text(encoding="utf-8") + cls.todo = (cls.root / "TODO.md").read_text(encoding="utf-8") + + def test_release_identity_is_final_and_no_provisional_claim_remains(self) -> None: + metadata = tomllib.loads((self.root / "pyproject.toml").read_text(encoding="utf-8")) + self.assertEqual("4.0.0", metadata["project"]["version"]) + self.assertIn("complete recreation", self.release.casefold()) + self.assertIn("complete recreation", self.readme.casefold()) + for provisional in ( + "being rebuilt", + "early v4 implementation", + "broader collection catalog is being rebuilt", + "to be released", + ): + with self.subTest(provisional=provisional): + self.assertNotIn(provisional, self.readme.casefold()) + self.assertNotIn( + provisional, + (self.root / "SECURITY.md").read_text(encoding="utf-8").casefold(), + ) + + def test_release_covers_every_specialty_mode_and_output_media_type(self) -> None: + report = preflight(self.root) + self.assertEqual((), report.invalid) + core = [candidate for candidate in report.valid if candidate.kind.value == "core"] + self.assertEqual(66, len(core)) + for specialty in Specialty: + with self.subTest(specialty=specialty.value): + self.assertIn(f"`{specialty.value}`", self.release) + for mode in EXECUTION_MODES: + with self.subTest(mode=mode): + self.assertIn(f"`{mode}`", self.release) + media_types = { + media_type + for candidate in core + if candidate.metadata is not None + for media_type in candidate.metadata.output_media_types + } + for media_type in media_types: + with self.subTest(media_type=media_type): + self.assertIn(f"`{media_type}`", self.release) + + def test_every_todo_section_has_exactly_one_owner_status_and_evidence_row(self) -> None: + headings = [ + line.lstrip("#").strip() + for line in self.todo.splitlines() + if re.match(r"^#{2,4} ", line) + ] + rows: dict[str, tuple[str, str, str]] = {} + for line in self.status.splitlines(): + if not line.startswith("| ") or line.startswith("| ---"): + continue + columns = [column.strip() for column in line.strip("|").split("|")] + if columns[0] == "TODO section": + continue + self.assertEqual(4, len(columns), line) + self.assertNotIn(columns[0], rows) + rows[columns[0]] = (columns[1], columns[2], columns[3]) + self.assertEqual(headings, list(rows)) + for heading, (owner, status, evidence) in rows.items(): + with self.subTest(heading=heading): + self.assertTrue(owner) + self.assertIn(status, {"Complete", "Wiki pending"}) + self.assertTrue(evidence) + + def test_only_the_external_wiki_documentation_item_remains_unchecked(self) -> None: + unchecked = [ + line.removeprefix("- [ ] ") + for line in self.todo.splitlines() + if line.startswith("- [ ] ") + ] + self.assertEqual( + ["Update README, contribution guidance, configuration documentation, and the"], + unchecked, + ) + self.assertIn("separate GitHub wiki checkout", self.release) + + def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> None: + documents = ( + "CONFIGURATION.md", "CONTRIBUTING.md", "OUTPUTS.md", "MODS.md", + "MIGRATION.md", "FLOW_MATRIX.md", "FEATURE_STATUS.md", "V4_RELEASE.md", + "SECURITY.md", "CODE_OF_CONDUCT.md", + ) + for document in documents: + with self.subTest(document=document): + self.assertIn(document, self.readme) + for option in ( + "--config", "--match", "--modes", "--profile", "--include", "--exclude", + "--plugins", "--mods", "--workers", "--allow-capability", "--rerun-from", + "--sequential", "--parallel", "--no-package", "--reboot", "--shutdown", + "--acknowledge-authorization", "--interactive", "--apply", + ): + with self.subTest(option=option): + self.assertIn(option, self.readme) + contributor = (self.root / "CONTRIBUTING.md").read_text(encoding="utf-8") + for requirement in ( + "Development setup", "Architecture boundaries", "Core collector changes", + "Plugins and MODs", "Configuration changes", "Testing expectations", + "conventional commit", "Developer Certificate of Origin", + ): + with self.subTest(requirement=requirement): + self.assertIn(requirement, contributor) + + def test_output_location_documentation_matches_run_owned_packaging(self) -> None: + migration = (self.root / "MIGRATION.md").read_text(encoding="utf-8") + self.assertIn("canonical packages are run-owned", self.readme) + self.assertIn("each run's `packages/` and", migration) + self.assertIn("not\npopulated with duplicate evidence", migration) + + +if __name__ == "__main__": + unittest.main() From 098434010033affb8d853e40ffe13297a4e5dd3e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 31 Aug 2026 18:43:32 +0300 Subject: [PATCH 213/316] docs: complete the v4 TODO and wiki synchronization Record the separately committed Logicytics 4.0 wiki rewrite, link the wiki from the user documentation, and promote every delivery-phase and completion status from wiki-pending to complete. Tick the final repository-wiki documentation requirement and strengthen release tests so any unticked TODO item or non-complete feature owner status fails verification. Verified the exact final state with all 209 tests, including release documentation, collector contracts, packaging integrity, and live bounded Windows integrations. --- FEATURE_STATUS.md | 10 +++++----- README.md | 2 ++ TODO.md | 5 +++-- V4_RELEASE.md | 8 +++++--- tests/test_release_documentation.py | 13 ++++++------- 5 files changed, 21 insertions(+), 17 deletions(-) diff --git a/FEATURE_STATUS.md b/FEATURE_STATUS.md index 336a7b21..212f8bbc 100644 --- a/FEATURE_STATUS.md +++ b/FEATURE_STATUS.md @@ -3,8 +3,8 @@ This register maps every section in [TODO.md](TODO.md) to its implementation owner, current status, and primary evidence. Every checklist item inherits the owner of its nearest heading; the checkbox beside that item is the authoritative -item-level status. `Wiki pending` means repository code and versioned docs are -complete but the separate GitHub wiki checkout still needs synchronization. +item-level status. The separate GitHub wiki was synchronized for v4.0 in wiki +commit `eaf7628`. | TODO section | Owner | Status | Primary evidence | | --- | --- | --- | --- | @@ -54,13 +54,13 @@ complete but the separate GitHub wiki checkout still needs synchronization. | Developer action (`--dev`) | Maintenance maintainers | Complete | integrity/version/developer check tests | | 6. MODS extension system | Extension maintainers | Complete | `MODS.md`, MOD runner and package tests | | 7. Configuration and output contract | Configuration/artifact maintainers | Complete | `CONFIGURATION.md`, `OUTPUTS.md`, contract tests | -| 8. v4.0 delivery phases | Release maintainers | Wiki pending | phases 1-4 complete; phase 5 awaits separate wiki checkout | +| 8. v4.0 delivery phases | Release maintainers | Complete | phases 1-5 complete, including wiki commit `eaf7628` | | Phase 1 — Foundation | Architecture maintainers | Complete | contracts, configuration, logging, adapters, run workspace | | Phase 2 — Engine and packaging | Runtime/packaging maintainers | Complete | scheduling, modes, ZIP/hash, MOD packaging tests | | Phase 3 — Core collectors | Core collector maintainers | Complete | all 66 core collectors pass strict preflight | | Phase 4 — Deep collection and visualization | Deep-collection maintainers | Complete | deep profile, packet graph, usage SVG tests | -| Phase 5 — Maintenance and release readiness | Release maintainers | Wiki pending | repository release gates complete; external wiki synchronization pending | -| 9. v4.0 completion checklist | Release maintainers | Wiki pending | repository checklist complete except the separate wiki update | +| Phase 5 — Maintenance and release readiness | Release maintainers | Complete | repository release gates and wiki commit `eaf7628` | +| 9. v4.0 completion checklist | Release maintainers | Complete | all repository and wiki checklist items verified | ## Maintenance rule diff --git a/README.md b/README.md index 207ef6c3..7b1e683f 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,8 @@ BitLocker, networking, Sysinternals, and artifact-publication probes. - [FLOW_MATRIX.md](FLOW_MATRIX.md) — executable evidence for every user flow. - [FEATURE_STATUS.md](FEATURE_STATUS.md) — TODO section owners and status. - [V4_RELEASE.md](V4_RELEASE.md) — final v4.0 recreation and release evidence. +- [Logicytics wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) — v4 user, + contributor, configuration, evidence, extension, and troubleshooting guides. - [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) — contributor and security policy. diff --git a/TODO.md b/TODO.md index 092769fc..157271c7 100644 --- a/TODO.md +++ b/TODO.md @@ -923,7 +923,7 @@ appropriate default/deep/non-Python mode. v4.0 documentation. - [x] Verify that packages contain generated evidence and metadata only. - [x] Verify that hashes reproduce from the final package bytes. -- [ ] Update README, contribution guidance, configuration documentation, and the +- [x] Update README, contribution guidance, configuration documentation, and the repository wiki for v4.0. ## 9. v4.0 completion checklist @@ -940,4 +940,5 @@ appropriate default/deep/non-Python mode. - [x] The final v4.0 release is documented as a complete recreation of the feature surface listed here. -Once all is complete, ask the user to git clone the wiki of logicytics so that we may update the DOCS completly +The separate Logicytics wiki was cloned and synchronized for v4.0 in wiki commit +`eaf7628`. diff --git a/V4_RELEASE.md b/V4_RELEASE.md index 85fce429..482003e8 100644 --- a/V4_RELEASE.md +++ b/V4_RELEASE.md @@ -8,9 +8,11 @@ interaction, maintenance, extension, output, and packaging capabilities while replacing the historical shared-script design with a typed, run-oriented, isolated architecture. -The repository implementation and versioned documentation are release-complete. -Synchronization of the separate GitHub wiki checkout is tracked independently in -the TODO because the wiki is not stored in this repository. +The repository implementation, versioned documentation, and separate +[GitHub wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) are +release-complete. The wiki's v4 user, contributor, configuration, evidence, +extension, migration, security, and troubleshooting pages were synchronized in +wiki commit `eaf7628`. ## Release identity diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py index 8c22c61b..37514d60 100644 --- a/tests/test_release_documentation.py +++ b/tests/test_release_documentation.py @@ -80,20 +80,19 @@ def test_every_todo_section_has_exactly_one_owner_status_and_evidence_row(self) for heading, (owner, status, evidence) in rows.items(): with self.subTest(heading=heading): self.assertTrue(owner) - self.assertIn(status, {"Complete", "Wiki pending"}) + self.assertEqual("Complete", status) self.assertTrue(evidence) - def test_only_the_external_wiki_documentation_item_remains_unchecked(self) -> None: + def test_every_todo_item_is_complete_and_wiki_evidence_is_recorded(self) -> None: unchecked = [ line.removeprefix("- [ ] ") for line in self.todo.splitlines() if line.startswith("- [ ] ") ] - self.assertEqual( - ["Update README, contribution guidance, configuration documentation, and the"], - unchecked, - ) - self.assertIn("separate GitHub wiki checkout", self.release) + self.assertEqual([], unchecked) + self.assertIn("GitHub wiki", self.release) + self.assertIn("eaf7628", self.release) + self.assertIn("eaf7628", self.status) def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> None: documents = ( From a27c74fa7cfd23be176358f67580f2a3705f9999 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:14:45 +0300 Subject: [PATCH 214/316] docs: updated docs and changed some locations of files --- CONFIGURATION.md | 207 ---------------------------- CONTRIBUTING.md | 22 +-- FEATURE_STATUS.md | 70 ---------- FLOW_MATRIX.md | 28 ---- README.md | 80 +++++------ SECURITY.md | 10 +- docs/CONFIGURATION.md | 207 ++++++++++++++++++++++++++++ docs/FEATURE_STATUS.md | 70 ++++++++++ docs/FLOW_MATRIX.md | 28 ++++ MIGRATION.md => docs/MIGRATION.md | 26 ++-- MODS.md => docs/MODS.md | 0 OUTPUTS.md => docs/OUTPUTS.md | 40 +++--- docs/README.md | 7 + TODO.md => docs/TODO.md | 14 +- V4_RELEASE.md => docs/V4_RELEASE.md | 6 +- 15 files changed, 411 insertions(+), 404 deletions(-) delete mode 100644 CONFIGURATION.md delete mode 100644 FEATURE_STATUS.md delete mode 100644 FLOW_MATRIX.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/FEATURE_STATUS.md create mode 100644 docs/FLOW_MATRIX.md rename MIGRATION.md => docs/MIGRATION.md (58%) rename MODS.md => docs/MODS.md (100%) rename OUTPUTS.md => docs/OUTPUTS.md (62%) create mode 100644 docs/README.md rename TODO.md => docs/TODO.md (99%) rename V4_RELEASE.md => docs/V4_RELEASE.md (96%) diff --git a/CONFIGURATION.md b/CONFIGURATION.md deleted file mode 100644 index 8f2e18b6..00000000 --- a/CONFIGURATION.md +++ /dev/null @@ -1,207 +0,0 @@ -# Logicytics v4 configuration - -Logicytics uses one strictly validated schema-version `4` JSON document. The -default location is `logicytics.json` in the project root; `--config PATH` selects -another file. The public Python API accepts the same path through -`load_configuration(project_root, config_path)` and the planning/run helpers. - -Configuration is immutable product policy. One-off choices such as a profile, -mode, explicit collector include/exclude list, authorization acknowledgement, -approved capabilities, worker override, output policy, rerun source, and power -action belong to `RunRequest` or CLI flags and never rewrite this file. - -## Complete example - -Every section is optional except `schema_version`. Omitted values use the defaults -listed below. - -```json -{ - "schema_version": 4, - "runtime": { - "output_root": "output/data", - "default_max_workers": 4, - "maximum_workers": 16, - "package_completed_runs": true, - "maximum_run_output_bytes": 4294967296 - }, - "interaction": { - "history_enabled": false, - "similarity_threshold": 0.55, - "model_name": "stdlib-sequence-matcher", - "model_debug": false - }, - "maintenance": { - "local_manifest_path": "project.manifest.json", - "minimum_python": "3.11", - "recommended_python": "3.11" - }, - "logging": { - "level": "INFO", - "console_enabled": true, - "color_enabled": true, - "file_enabled": true, - "maximum_bytes": 4194304, - "delete_previous": false, - "retention_days": 30 - }, - "collectors": { - "core.network.bandwidth_sample": { - "sample_count": 3, - "interval_seconds": 1.0 - }, - "core.filesystem.system_drive_tree": { - "max_entries": 5000, - "max_depth": 12 - } - } -} -``` - -## Source selection and precedence - -1. An explicit `--config PATH` or API `config_path` is loaded. -2. Otherwise, project-root `logicytics.json` is loaded when present. -3. Otherwise, historical `CODE/config.ini` is translated in memory when present. -4. Otherwise, safe schema-v4 defaults are used. - -The modern JSON file always wins over the INI fallback. Loading never modifies -the selected source. A configuration file larger than 2 MiB, malformed UTF-8 or -JSON, duplicate object key, non-finite number, wrong type, unknown engine field, -or unsupported schema fails before planning or collection. - -## Root object - -| Setting | Required | Meaning | -| --- | --- | --- | -| `schema_version` | yes | Integer `4`; schema `3` is accepted only through the documented migration path. | -| `runtime` | no | Engine-wide paths, concurrency, packaging, and aggregate-output limits. | -| `interaction` | no | Local semantic matching and opt-in history behavior. | -| `maintenance` | no | Integrity manifests and supported Python policy. | -| `logging` | no | Human application-log and console presentation policy. | -| `collectors` | no | A map from exact collector IDs to isolated collector settings. | - -Unknown root fields are rejected. In particular, configuration cannot implicitly -enable plugins, MODs, collectors, capabilities, authorization, or a power action. - -## Runtime settings - -| Setting | Default | Accepted value | Effect | -| --- | --- | --- | --- | -| `output_root` | `output/data` | Non-empty absolute path, or a path resolved relative to the project root | Owns run directories, evidence, manifests, packages, and hashes. | -| `default_max_workers` | `4` | Integer from 1 to 64, not greater than `maximum_workers` | Worker count used when the request/mode does not force a strategy. | -| `maximum_workers` | `16` | Integer from 1 to 64, not less than `default_max_workers` | Hard ceiling for CLI and API worker overrides. | -| `package_completed_runs` | `true` | Boolean | When false, publish the run tree and manifest without creating a ZIP or ZIP hash. | -| `maximum_run_output_bytes` | `4294967296` (4 GiB) | Integer from 1 through `68719476736` (64 GiB) | Aggregate artifact budget shared by the run; later collectors fail or skip predictably when exhausted. | - -Completed run evidence is retained until the user explicitly removes its run -directory. The logging retention setting below applies only to application logs. - -## Interaction settings - -| Setting | Default | Accepted value | Effect | -| --- | --- | --- | --- | -| `history_enabled` | `false` | Boolean | Persist compressed local match history only when enabled. | -| `similarity_threshold` | `0.55` | Finite number from 0 through 1 | Minimum direct semantic-match score. | -| `model_name` | `stdlib-sequence-matcher` | Non-empty single-line string | Label recorded with matcher results; v4 uses its standard-library matcher. | -| `model_debug` | `false` | Boolean | Include matcher diagnostics independently of application debug logging. | - -History records remain local and contain the matched flag, input, accuracy, -timestamp, device name, and per-flag usage. Disabling history prevents new -records; it does not silently delete an existing history file. - -## Maintenance settings - -| Setting | Default | Accepted value | Effect | -| --- | --- | --- | --- | -| `remote_manifest_url` | none | HTTPS URL, configured together with `remote_manifest_sha256` | Optional integrity-manifest source used by maintenance actions, never by collection. | -| `remote_manifest_sha256` | none | Exactly 64 lowercase hexadecimal characters, configured with the URL | Authenticates the exact downloaded manifest bytes before parsing. | -| `local_manifest_path` | `project.manifest.json` | Non-empty relative path without `..` | Project-confined integrity manifest used by debug/developer checks. | -| `minimum_python` | `3.11` | `major.minor` | Oldest supported interpreter reported by diagnostics. | -| `recommended_python` | `3.11` | `major.minor`, not older than the minimum | Recommended interpreter reported by diagnostics. | - -An integrity manifest may contain only its schema version, project version, and -file hashes. It cannot configure execution. Remote retrieval is optional, -authenticated, bounded, and isolated from normal collection. - -## Logging settings - -| Setting | Default | Accepted value | Effect | -| --- | --- | --- | --- | -| `level` | `INFO` | `DEBUG`, `INTERNAL`, `INFO`, `WARNING`, `ERROR`, `EXCEPTION`, or `CRITICAL` (case-insensitive) | Minimum application-log level. | -| `console_enabled` | `true` | Boolean | Write redacted human rows to stderr. | -| `color_enabled` | `true` | Boolean | Add ANSI colors only when the console is a TTY. | -| `file_enabled` | `true` | Boolean | Write redacted human rows to `output/logs/Logicytics.log`. | -| `maximum_bytes` | `4194304` (4 MiB) | Integer from 1024 through `67108864` (64 MiB) | Retain recent complete rows within the application-log byte cap. | -| `delete_previous` | `false` | Boolean | Explicitly remove the current application log when the logger starts. | -| `retention_days` | `30` | Integer from 0 through 3650 | Remove expired rotated `Logicytics*.log` files; does not remove run evidence. | - -Engine and collector JSONL event streams are always run-owned and separate from -the human application log. All channels redact sensitive fields before writing. - -## Shipped collector settings - -The engine validates the following core fields before planning. A collector also -revalidates settings inside its isolated worker. Unknown fields for these shipped -collectors are rejected. - -| Collector | Setting | Default | Accepted value | -| --- | --- | --- | --- | -| `core.network.bandwidth_sample` | `sample_count` | `3` | Integer 1-10. | -| `core.network.bandwidth_sample` | `interval_seconds` | `1.0` | Finite number 0.1-60 seconds. | -| `core.packet.packet_capture` | `packet_count` | `100` | Integer 1-10000. | -| `core.packet.packet_capture` | `timeout_seconds` | `10` | Finite number 1-60 seconds. | -| `core.packet.packet_capture` | `retry_window_seconds` | `0` | Finite number 0-60 seconds. | -| `core.packet.packet_capture` | `interface` | local host IPv4 address | Non-empty text without a NUL byte. | -| `core.filesystem.system_drive_tree` | `max_entries` | `5000` | Integer 1-50000. | -| `core.filesystem.system_drive_tree` | `max_depth` | `12` | Integer 1-32. | -| `core.filesystem.system_drive_listing` | `max_entries` | `10000` | Integer 1-50000. | -| `core.filesystem.system_drive_listing` | `max_depth` | `16` | Integer 1-32. | -| `core.filesystem.sensitive_file_inventory` | `root` | Windows system-drive root | Non-empty absolute filesystem path. | -| `core.filesystem.sensitive_file_inventory` | `max_directories` | `5000` | Integer 1-50000. | -| `core.filesystem.sensitive_file_inventory` | `max_matches` | `500` | Integer 1-5000. | -| `core.process.memory_map` | `max_regions` | `5000` | Integer 1-100000. | -| `core.process.memory_map` | `output_limit_bytes` | `67108864` (64 MiB) | Integer 1024-67108864. | -| `core.process.memory_map` | `disk_safety_margin_bytes` | `104857600` (100 MiB) | Integer 0-68719476736. | -| `core.process.memory_map` | `dump_directory` | `memory_maps` | Non-empty relative collector-workspace path without `..`. | - -Shipped core collectors without a table above do not declare configurable fields, -so a non-empty settings object for one is rejected. Syntactically valid plugin- -and MOD-owned fields remain extensible for their extension contracts, but they do -not bypass capability, workspace, artifact, timeout, or output limits. - -## Schema 3 and INI migration - -Schema-version `3` JSON is translated in memory and recorded as -`migrated_from_schema: 3` in the run manifest. Root aliases `workers` and -`worker_count` become `runtime.default_max_workers`; `max_workers` becomes -`runtime.maximum_workers`; `output_root`, `package_completed_runs`, and -`maximum_run_output_bytes` move under `runtime`; and `collector_settings` becomes -`collectors`. Historical `ACCESS/RUNS` resolves to `output/data`. Conflicting old -and new names fail closed instead of choosing one silently. - -The read-only `CODE/config.ini` bridge translates these historical fields: - -- `Settings`: `max_workers`, `log_using_debug`, `delete_old_logs`, and - `save_preferences`. -- `Flag Settings`: `accuracy_min`, `model_to_use`, and `model_debug`. -- `DumpMemory Settings`: `file_size_limit` and `file_size_safety`. -- `NetWorkPsutil Settings`: `sample_count` and `interval`. -- `PacketSniffer Settings`: `interface`, `packet_count`, `timeout`, and - `max_retry_time`. - -Use a schema-v4 JSON file for all new configuration. See [MIGRATION.md](MIGRATION.md) -for the broader v3-to-v4 runtime and extension compatibility boundary. - -## Validation and inspection - -Validate discovery, configuration, and host prerequisites without collecting: - -```powershell -python -m logicytics --config .\logicytics.json preflight -python -m logicytics --config .\logicytics.json plan --profile standard -``` - -The run manifest records a redacted configuration snapshot and deterministic -configuration fingerprint. Use `query_run` to inspect the resolved plan, -collector results, artifacts, failures, and hashes after a run. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0605379..282812e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,15 +86,15 @@ use a sidecar declaration and may be Python, PowerShell, batch, or executable payloads. Neither extension type may bypass preflight, planning, capability approval, isolated workspaces, artifact registration, or package filtering. -Read [MODS.md](MODS.md) before changing discovery or extension behavior. Read -[MIGRATION.md](MIGRATION.md) before changing a legacy flag, schema migration, +Read [MODS.md](docs/MODS.md) before changing discovery or extension behavior. Read +[MIGRATION.md](docs/MIGRATION.md) before changing a legacy flag, schema migration, historical `CODE` evidence import, or MOD adapter. Compatibility code must remain a bounded translation into the canonical v4 model. ## Configuration changes Configuration changes must preserve the strict schema in -[CONFIGURATION.md](CONFIGURATION.md). Update the field reference and its +[CONFIGURATION.md](docs/CONFIGURATION.md). Update the field reference and its parser-backed tests in the same commit as any setting, default, bound, migration alias, or source-precedence change. @@ -105,7 +105,7 @@ They do not belong in persistent configuration. ## Evidence, security, and compatibility -- Update [OUTPUTS.md](OUTPUTS.md) when a filename, MIME type, package path, +- Update [OUTPUTS.md](docs/OUTPUTS.md) when a filename, MIME type, package path, evidence kind, or retention rule changes. - Keep source, executables, models, configuration secrets, caches, and library internals out of evidence packages. @@ -144,13 +144,13 @@ Keep user and developer documentation synchronized with behavior: - [README.md](README.md): installation, quick start, CLI, permissions, and troubleshooting. -- [CONFIGURATION.md](CONFIGURATION.md): every persistent setting and migration. -- [OUTPUTS.md](OUTPUTS.md): artifact and retention contracts. -- [MODS.md](MODS.md): extension contract. -- [MIGRATION.md](MIGRATION.md): supported compatibility boundary. -- [FLOW_MATRIX.md](FLOW_MATRIX.md): executable flow evidence. -- [FEATURE_STATUS.md](FEATURE_STATUS.md): TODO ownership and completion evidence. -- [V4_RELEASE.md](V4_RELEASE.md): v4 recreation scope and release verification. +- [CONFIGURATION.md](docs/CONFIGURATION.md): every persistent setting and migration. +- [OUTPUTS.md](docs/OUTPUTS.md): artifact and retention contracts. +- [MODS.md](docs/MODS.md): extension contract. +- [MIGRATION.md](docs/MIGRATION.md): supported compatibility boundary. +- [FLOW_MATRIX.md](docs/FLOW_MATRIX.md): executable flow evidence. +- [FEATURE_STATUS.md](docs/FEATURE_STATUS.md): TODO ownership and completion evidence. +- [V4_RELEASE.md](docs/V4_RELEASE.md): v4 recreation scope and release verification. The repository wiki is complementary documentation, not a substitute for the versioned contract files required to review a change. diff --git a/FEATURE_STATUS.md b/FEATURE_STATUS.md deleted file mode 100644 index 212f8bbc..00000000 --- a/FEATURE_STATUS.md +++ /dev/null @@ -1,70 +0,0 @@ -# v4 feature ownership and status - -This register maps every section in [TODO.md](TODO.md) to its implementation -owner, current status, and primary evidence. Every checklist item inherits the -owner of its nearest heading; the checkbox beside that item is the authoritative -item-level status. The separate GitHub wiki was synchronized for v4.0 in wiki -commit `eaf7628`. - -| TODO section | Owner | Status | Primary evidence | -| --- | --- | --- | --- | -| Core product redesign — mandatory v4.0 change | Architecture maintainers | Complete | `logicytics/contracts.py`, `planner.py`, `runtime.py`, `packaging.py` | -| Core functionality status | Runtime maintainers | Complete | `tests/test_core_functionality.py`, `tests/test_flow_matrix.py` | -| Core and plugin layout — canonical v4.0 model | Discovery maintainers | Complete | `core/`, `plugins/`, `logicytics/discovery.py` | -| Strict collector file, class, and specialty standards | Collector contract maintainers | Complete | `logicytics/contracts.py`, `discovery.py`, `tests/test_shipped_collectors.py` | -| File and folder rules | Discovery maintainers | Complete | `logicytics/discovery.py`, static preflight tests | -| Class and identity rules | Collector contract maintainers | Complete | `CollectorMetadata`, strict preflight tests | -| Specialty rules | Collector contract maintainers | Complete | `Specialty`, shipped collector catalog tests | -| Required class interface | Collector contract maintainers | Complete | `CoreCollector`, `PluginCollector`, lifecycle tests | -| Static and runtime preflight validation | Discovery maintainers | Complete | `logicytics/discovery.py`, `validation_worker.py` | -| Per-collector sandbox and failure isolation | Runtime maintainers | Complete | `logicytics/runtime.py`, cancellation/isolation tests | -| Replace the current architectural model | Architecture maintainers | Complete | v4 packages and `MIGRATION.md` | -| Define the v4.0 run model | Runtime and manifest maintainers | Complete | `runtime.py`, `manifest.py`, package integration tests | -| Separate responsibilities into layers | Architecture maintainers | Complete | CLI, planner, runtime, adapters, artifacts, packaging, maintenance modules | -| Make collection safe, bounded, and observable | Runtime and security maintainers | Complete | capability metadata, worker limits, progress/failure tests | -| Make concurrency a planned capability | Planner/runtime maintainers | Complete | resource scheduler and concurrency tests | -| Make configuration and profiles understandable | Configuration/planner maintainers | Complete | `CONFIGURATION.md`, `configuration.py`, profile tests | -| Improve the extension model | Extension maintainers | Complete | `MODS.md`, plugin/MOD discovery and isolation tests | -| Improve packaging and evidence integrity | Artifact/packaging maintainers | Complete | `OUTPUTS.md`, package/hash reproduction tests | -| Improve the public API and testability | API maintainers | Complete | `logicytics/api.py`, public API tests | -| Migration rules from the old design | Compatibility maintainers | Complete | `MIGRATION.md`, migration regression tests | -| v4.0 goals | Release maintainers | Complete | `V4_RELEASE.md` and full release gates | -| 1. Main engine and execution lifecycle | Runtime maintainers | Complete | CLI/planner/runtime integration tests | -| Execution modes | Mode/planner maintainers | Complete | `logicytics/modes.py`, machine-readable mode matrix tests | -| Post-run actions | Runtime maintainers | Complete | typed shutdown/reboot package-gating tests | -| 2. Command-line and interaction features | CLI/interaction maintainers | Complete | `cli.py`, `interaction.py`, usage/matching tests | -| 3. Core Logicytics engine/library API | API maintainers | Complete | public lazy API and integration tests | -| Command execution | Platform adapter maintainers | Complete | `ProcessAdapter`, command/MOD tests | -| File discovery | Discovery maintainers | Complete | `file_listing.py`, recursive discovery tests | -| Environment checks | Platform adapter maintainers | Complete | `environment.py`, adapter and live Windows tests | -| Configuration | Configuration maintainers | Complete | `CONFIGURATION.md`, parser/migration tests | -| Logging | Logging maintainers | Complete | `logging.py`, singleton/redaction/retention tests | -| Output and packaging | Artifact/packaging maintainers | Complete | `artifacts.py`, `packaging.py`, `OUTPUTS.md` | -| 4. Standard system-data collectors | Core collector maintainers | Complete | 66-collector preflight, lifecycle, output, and Windows tests | -| System, hardware, and operating-system inventory | System/hardware collector maintainers | Complete | `core/system/`, `core/hardware/` | -| Network inventory and activity | Network/packet collector maintainers | Complete | `core/network/`, `core/packet/` | -| Wireless, Bluetooth, and removable-device history | Wireless/device collector maintainers | Complete | `core/wireless/`, `core/bluetooth/`, `core/usb/` | -| Windows logs, registry, storage, and encryption | Windows evidence collector maintainers | Complete | `core/event_log/`, `registry/`, `storage/`, `encryption/` | -| Memory and process diagnostics | Process/memory collector maintainers | Complete | `core/process/`, `core/memory/` | -| User files and media | File evidence collector maintainers | Complete | `core/browser/`, `filesystem/`, `media/`, `ssh/` | -| Sysinternals integration | Diagnostics collector maintainers | Complete | `core/diagnostics/sysinternals_report.py`, `sysinternals.py` | -| 5. Maintenance and developer features | Maintenance maintainers | Complete | `maintenance.py`, CLI maintenance flow tests | -| Debugger (`--debug`) | Maintenance maintainers | Complete | debug diagnostics tests and `FLOW_MATRIX.md` | -| Update action (`--update`) | Maintenance maintainers | Complete | authenticated/read-only check and explicit apply tests | -| Developer action (`--dev`) | Maintenance maintainers | Complete | integrity/version/developer check tests | -| 6. MODS extension system | Extension maintainers | Complete | `MODS.md`, MOD runner and package tests | -| 7. Configuration and output contract | Configuration/artifact maintainers | Complete | `CONFIGURATION.md`, `OUTPUTS.md`, contract tests | -| 8. v4.0 delivery phases | Release maintainers | Complete | phases 1-5 complete, including wiki commit `eaf7628` | -| Phase 1 — Foundation | Architecture maintainers | Complete | contracts, configuration, logging, adapters, run workspace | -| Phase 2 — Engine and packaging | Runtime/packaging maintainers | Complete | scheduling, modes, ZIP/hash, MOD packaging tests | -| Phase 3 — Core collectors | Core collector maintainers | Complete | all 66 core collectors pass strict preflight | -| Phase 4 — Deep collection and visualization | Deep-collection maintainers | Complete | deep profile, packet graph, usage SVG tests | -| Phase 5 — Maintenance and release readiness | Release maintainers | Complete | repository release gates and wiki commit `eaf7628` | -| 9. v4.0 completion checklist | Release maintainers | Complete | all repository and wiki checklist items verified | - -## Maintenance rule - -Any new TODO heading must be added to this register in the same commit. Any item -whose owner, status, or evidence changes must update this table and the relevant -versioned contract document. The documentation contract test enforces exact -heading coverage so features cannot become ownerless. diff --git a/FLOW_MATRIX.md b/FLOW_MATRIX.md deleted file mode 100644 index 3d3f169e..00000000 --- a/FLOW_MATRIX.md +++ /dev/null @@ -1,28 +0,0 @@ -# v4 execution-flow verification matrix - -This matrix names the executable regression evidence for every supported v4 -mode, maintenance action, post-run action, and terminal safety flow. The test -suite verifies that every referenced test remains present and then executes the -tests normally. - -| Flow | Executable evidence | -| --- | --- | -| default / standard | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | -| threaded / balanced | `test_explicit_execution_modes_control_isolated_worker_overlap` | -| minimal / quick | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | -| deep / thorough | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | -| non-Python | `test_nopy_and_modded_modes_select_declared_mod_types_without_helpers` | -| performance | `test_performance_report_is_finalized_before_automatic_packaging` | -| modded / extensions | `test_mods_require_sidecars_and_run_as_isolated_registered_artifacts` | -| debug | `test_dev_writes_explicit_manifest_and_debug_persists_diagnostics` | -| update | `test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window` | -| usage | `test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in` | -| shutdown and reboot | `test_post_run_actions_are_typed_exclusive_and_require_verified_packaging` | -| cancellation | `test_cancelled_run_writes_a_recoverable_package_and_manifest` | -| permission denied | `test_access_denied_is_skipped_not_failed` | - -The mode registry test covers named modes and every historical flag alias. The -overlap test executes real isolated workers. MOD and non-Python tests exercise -sidecar discovery and native adapters. Maintenance tests keep update/debug out of -normal collection. Power actions require a verified package. Cancellation and -permission tests prove these states cannot be misreported as success. diff --git a/README.md b/README.md index 7b1e683f..7100e1b5 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@ and evidence-packaging tool around an explicit run-oriented pipeline: The release ships 66 independently runnable core collectors, typed profiles and modes, opt-in plugins and MODs, isolated workers, reproducible manifests and -packages, and bounded Windows integrations. See [V4_RELEASE.md](V4_RELEASE.md) -for the release scope and [FEATURE_STATUS.md](FEATURE_STATUS.md) for ownership +packages, and bounded Windows integrations. See [V4_RELEASE.md](docs/V4_RELEASE.md) +for the release scope and [FEATURE_STATUS.md](docs/FEATURE_STATUS.md) for ownership and verification evidence. ## Current capabilities @@ -105,12 +105,12 @@ on absent, non-integer, or unsupported schema versions. The manifest separately records package layout version `1.0` and every section path; the package SHA-256 remains an external sidecar because an archive cannot contain its own final digest. Every shipped collector's stable filename or path pattern, MIME format, canonical -package location, and retention behavior is defined in [OUTPUTS.md](OUTPUTS.md) +package location, and retention behavior is defined in [OUTPUTS.md](docs/OUTPUTS.md) and enforced by the isolated artifact writer. Supported legacy inputs, their exact v4 replacements, and removed internal shims -are documented in [MIGRATION.md](MIGRATION.md). +are documented in [MIGRATION.md](docs/MIGRATION.md). The executable evidence for every supported mode, maintenance action, power -action, cancellation, and permission flow is indexed in [FLOW_MATRIX.md](FLOW_MATRIX.md). +action, cancellation, and permission flow is indexed in [FLOW_MATRIX.md](docs/FLOW_MATRIX.md). ## Requirements @@ -219,20 +219,20 @@ python -m logicytics run --profile standard --acknowledge-authorization ` Important global and run controls: -| Control | Purpose | -| --- | --- | -| `--config PATH` | Load a schema-v4 JSON or supported legacy INI configuration. | -| `--match TEXT` | Suggest the closest documented action; history persists only when configured. | -| `--modes` | Print the versioned collector/mode matrix. | -| `run --mode NAME` | Select one canonical execution mode. | -| `--include ID` / `--exclude ID` | Override profile membership by exact collector ID. | -| `--plugins` / `--mods` | Opt into discovered plugins or declared MODs. | -| `--workers COUNT` | Override bounded concurrency within the configured maximum. | -| `--sequential` / `--parallel` | Select explicit scheduling without a legacy mode alias. | -| `--rerun-from MANIFEST` | Rerun selected collectors while preserving parent-run provenance. | -| `--no-package` | Retain the manifest/run tree without a ZIP or ZIP hash. | -| `--reboot` / `--shutdown` | Schedule one mutually exclusive power action only after verified packaging. | -| `update --apply` | Explicitly apply the configured Git update; an ordinary update check is read-only. | +| Control | Purpose | +|---------------------------------|------------------------------------------------------------------------------------| +| `--config PATH` | Load a schema-v4 JSON or supported legacy INI configuration. | +| `--match TEXT` | Suggest the closest documented action; history persists only when configured. | +| `--modes` | Print the versioned collector/mode matrix. | +| `run --mode NAME` | Select one canonical execution mode. | +| `--include ID` / `--exclude ID` | Override profile membership by exact collector ID. | +| `--plugins` / `--mods` | Opt into discovered plugins or declared MODs. | +| `--workers COUNT` | Override bounded concurrency within the configured maximum. | +| `--sequential` / `--parallel` | Select explicit scheduling without a legacy mode alias. | +| `--rerun-from MANIFEST` | Rerun selected collectors while preserving parent-run provenance. | +| `--no-package` | Retain the manifest/run tree without a ZIP or ZIP hash. | +| `--reboot` / `--shutdown` | Schedule one mutually exclusive power action only after verified packaging. | +| `update --apply` | Explicitly apply the configured Git update; an ordinary update check is read-only. | Legacy `--default`, `--threaded`, `--minimal`, `--depth`, `--modded`, `--nopy`, and `--performance-check` flags remain exact aliases. They cannot be combined @@ -287,16 +287,16 @@ mode assignments, validation state, execution type, and either `manual_only` or quarantine status. New integrations should use `run --mode `; the historical flags remain exact compatibility aliases. -| Mode | Profile | Scheduling | Legacy alias | Additional behavior | -| --- | --- | --- | --- | --- | -| `standard` | standard | sequential | `--default` | deterministic standard run | -| `balanced` | standard | bounded parallel | `--threaded` | configured worker pool | -| `quick` | minimal | configured | `--minimal` | essential inventory | -| `thorough` | deep | configured | `--depth` | extended slower inventory | -| `offline` | offline | configured | none | forbids network access | -| `extensions` | standard | configured | `--modded` | enables declared MODS | -| `non-python` | standard | configured | `--nopy` | only non-Python MODS payloads | -| `performance` | standard | sequential | `--performance-check` | duration report | +| Mode | Profile | Scheduling | Legacy alias | Additional behavior | +|---------------|----------|------------------|-----------------------|-------------------------------| +| `standard` | standard | sequential | `--default` | deterministic standard run | +| `balanced` | standard | bounded parallel | `--threaded` | configured worker pool | +| `quick` | minimal | configured | `--minimal` | essential inventory | +| `thorough` | deep | configured | `--depth` | extended slower inventory | +| `offline` | offline | configured | none | forbids network access | +| `extensions` | standard | configured | `--modded` | enables declared MODS | +| `non-python` | standard | configured | `--nopy` | only non-Python MODS payloads | +| `performance` | standard | sequential | `--performance-check` | duration report | Explicit `--profile`, `--include`, and `--exclude` remain available for advanced planning. A named mode cannot be combined with a different explicit profile or @@ -342,7 +342,7 @@ before asking Windows to open the file through its associated application. ## Configuration The complete field reference, limits, source precedence, migration rules, and a -parser-verified example are in [CONFIGURATION.md](CONFIGURATION.md). +parser-verified example are in [CONFIGURATION.md](docs/CONFIGURATION.md). An optional project-root `logicytics.json` uses schema version `4`. Product policy is split into immutable `runtime`, `interaction`, `maintenance`, and @@ -460,8 +460,8 @@ classification across the isolated worker boundary. Malformed core collectors block a run. Malformed unselected plugins are quarantined and listed by preflight; malformed explicitly selected plugins block -the requested run. See [FEATURE_STATUS.md](FEATURE_STATUS.md) for implemented -ownership and [V4_RELEASE.md](V4_RELEASE.md) for the released feature surface. +the requested run. See [FEATURE_STATUS.md](docs/FEATURE_STATUS.md) for implemented +ownership and [V4_RELEASE.md](docs/V4_RELEASE.md) for the released feature surface. ## Verification @@ -494,19 +494,19 @@ BitLocker, networking, Sysinternals, and artifact-publication probes. adjacent `.sha256` sidecar and rerun collection; package verification fails closed on modified bytes. - **Output is unexpectedly large:** lower collector-specific bounds in - [CONFIGURATION.md](CONFIGURATION.md) or use `minimal`/`standard`; the aggregate + [CONFIGURATION.md](docs/CONFIGURATION.md) or use `minimal`/`standard`; the aggregate run budget prevents unbounded publication. ## Documentation -- [CONFIGURATION.md](CONFIGURATION.md) — complete persistent settings contract. -- [OUTPUTS.md](OUTPUTS.md) — every collector path, format, package path, and +- [CONFIGURATION.md](docs/CONFIGURATION.md) — complete persistent settings contract. +- [OUTPUTS.md](docs/OUTPUTS.md) — every collector path, format, package path, and retention rule. -- [MODS.md](MODS.md) — opt-in MOD extension contract. -- [MIGRATION.md](MIGRATION.md) — supported v3 and legacy compatibility boundary. -- [FLOW_MATRIX.md](FLOW_MATRIX.md) — executable evidence for every user flow. -- [FEATURE_STATUS.md](FEATURE_STATUS.md) — TODO section owners and status. -- [V4_RELEASE.md](V4_RELEASE.md) — final v4.0 recreation and release evidence. +- [MODS.md](docs/MODS.md) — opt-in MOD extension contract. +- [MIGRATION.md](docs/MIGRATION.md) — supported v3 and legacy compatibility boundary. +- [FLOW_MATRIX.md](docs/FLOW_MATRIX.md) — executable evidence for every user flow. +- [FEATURE_STATUS.md](docs/FEATURE_STATUS.md) — TODO section owners and status. +- [V4_RELEASE.md](docs/V4_RELEASE.md) — final v4.0 recreation and release evidence. - [Logicytics wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) — v4 user, contributor, configuration, evidence, extension, and troubleshooting guides. - [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and diff --git a/SECURITY.md b/SECURITY.md index 70f6b254..9b54be81 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,13 @@ ## Supported versions -| Version | Security support | Release date | -| --- | --- | --- | -| 4.0.x | Supported | 2026-08-31 | -| 3.x and earlier | Unsupported | — | +| Version | Security support | Release date | +|-----------------|------------------|--------------| +| 4.0.x | Supported | 2026-08-31 | +| 3.x and earlier | Unsupported | — | Only the current v4 release line receives security fixes. Upgrade through the -documented [migration boundary](MIGRATION.md); do not run an unsupported checkout +documented [migration boundary](docs/MIGRATION.md); do not run an unsupported checkout against sensitive evidence. ## Reporting a vulnerability diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 00000000..015a2e28 --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,207 @@ +# Logicytics v4 configuration + +Logicytics uses one strictly validated schema-version `4` JSON document. The +default location is `logicytics.json` in the project root; `--config PATH` selects +another file. The public Python API accepts the same path through +`load_configuration(project_root, config_path)` and the planning/run helpers. + +Configuration is immutable product policy. One-off choices such as a profile, +mode, explicit collector include/exclude list, authorization acknowledgement, +approved capabilities, worker override, output policy, rerun source, and power +action belong to `RunRequest` or CLI flags and never rewrite this file. + +## Complete example + +Every section is optional except `schema_version`. Omitted values use the defaults +listed below. + +```json +{ + "schema_version": 4, + "runtime": { + "output_root": "output/data", + "default_max_workers": 4, + "maximum_workers": 16, + "package_completed_runs": true, + "maximum_run_output_bytes": 4294967296 + }, + "interaction": { + "history_enabled": false, + "similarity_threshold": 0.55, + "model_name": "stdlib-sequence-matcher", + "model_debug": false + }, + "maintenance": { + "local_manifest_path": "project.manifest.json", + "minimum_python": "3.11", + "recommended_python": "3.11" + }, + "logging": { + "level": "INFO", + "console_enabled": true, + "color_enabled": true, + "file_enabled": true, + "maximum_bytes": 4194304, + "delete_previous": false, + "retention_days": 30 + }, + "collectors": { + "core.network.bandwidth_sample": { + "sample_count": 3, + "interval_seconds": 1.0 + }, + "core.filesystem.system_drive_tree": { + "max_entries": 5000, + "max_depth": 12 + } + } +} +``` + +## Source selection and precedence + +1. An explicit `--config PATH` or API `config_path` is loaded. +2. Otherwise, project-root `logicytics.json` is loaded when present. +3. Otherwise, historical `CODE/config.ini` is translated in memory when present. +4. Otherwise, safe schema-v4 defaults are used. + +The modern JSON file always wins over the INI fallback. Loading never modifies +the selected source. A configuration file larger than 2 MiB, malformed UTF-8 or +JSON, duplicate object key, non-finite number, wrong type, unknown engine field, +or unsupported schema fails before planning or collection. + +## Root object + +| Setting | Required | Meaning | +|------------------|----------|---------------------------------------------------------------------------------| +| `schema_version` | yes | Integer `4`; schema `3` is accepted only through the documented migration path. | +| `runtime` | no | Engine-wide paths, concurrency, packaging, and aggregate-output limits. | +| `interaction` | no | Local semantic matching and opt-in history behavior. | +| `maintenance` | no | Integrity manifests and supported Python policy. | +| `logging` | no | Human application-log and console presentation policy. | +| `collectors` | no | A map from exact collector IDs to isolated collector settings. | + +Unknown root fields are rejected. In particular, configuration cannot implicitly +enable plugins, MODs, collectors, capabilities, authorization, or a power action. + +## Runtime settings + +| Setting | Default | Accepted value | Effect | +|----------------------------|----------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| `output_root` | `output/data` | Non-empty absolute path, or a path resolved relative to the project root | Owns run directories, evidence, manifests, packages, and hashes. | +| `default_max_workers` | `4` | Integer from 1 to 64, not greater than `maximum_workers` | Worker count used when the request/mode does not force a strategy. | +| `maximum_workers` | `16` | Integer from 1 to 64, not less than `default_max_workers` | Hard ceiling for CLI and API worker overrides. | +| `package_completed_runs` | `true` | Boolean | When false, publish the run tree and manifest without creating a ZIP or ZIP hash. | +| `maximum_run_output_bytes` | `4294967296` (4 GiB) | Integer from 1 through `68719476736` (64 GiB) | Aggregate artifact budget shared by the run; later collectors fail or skip predictably when exhausted. | + +Completed run evidence is retained until the user explicitly removes its run +directory. The logging retention setting below applies only to application logs. + +## Interaction settings + +| Setting | Default | Accepted value | Effect | +|------------------------|---------------------------|--------------------------------|----------------------------------------------------------------------------| +| `history_enabled` | `false` | Boolean | Persist compressed local match history only when enabled. | +| `similarity_threshold` | `0.55` | Finite number from 0 through 1 | Minimum direct semantic-match score. | +| `model_name` | `stdlib-sequence-matcher` | Non-empty single-line string | Label recorded with matcher results; v4 uses its standard-library matcher. | +| `model_debug` | `false` | Boolean | Include matcher diagnostics independently of application debug logging. | + +History records remain local and contain the matched flag, input, accuracy, +timestamp, device name, and per-flag usage. Disabling history prevents new +records; it does not silently delete an existing history file. + +## Maintenance settings + +| Setting | Default | Accepted value | Effect | +|--------------------------|-------------------------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------| +| `remote_manifest_url` | none | HTTPS URL, configured together with `remote_manifest_sha256` | Optional integrity-manifest source used by maintenance actions, never by collection. | +| `remote_manifest_sha256` | none | Exactly 64 lowercase hexadecimal characters, configured with the URL | Authenticates the exact downloaded manifest bytes before parsing. | +| `local_manifest_path` | `project.manifest.json` | Non-empty relative path without `..` | Project-confined integrity manifest used by debug/developer checks. | +| `minimum_python` | `3.11` | `major.minor` | Oldest supported interpreter reported by diagnostics. | +| `recommended_python` | `3.11` | `major.minor`, not older than the minimum | Recommended interpreter reported by diagnostics. | + +An integrity manifest may contain only its schema version, project version, and +file hashes. It cannot configure execution. Remote retrieval is optional, +authenticated, bounded, and isolated from normal collection. + +## Logging settings + +| Setting | Default | Accepted value | Effect | +|-------------------|-------------------|------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| `level` | `INFO` | `DEBUG`, `INTERNAL`, `INFO`, `WARNING`, `ERROR`, `EXCEPTION`, or `CRITICAL` (case-insensitive) | Minimum application-log level. | +| `console_enabled` | `true` | Boolean | Write redacted human rows to stderr. | +| `color_enabled` | `true` | Boolean | Add ANSI colors only when the console is a TTY. | +| `file_enabled` | `true` | Boolean | Write redacted human rows to `output/logs/Logicytics.log`. | +| `maximum_bytes` | `4194304` (4 MiB) | Integer from 1024 through `67108864` (64 MiB) | Retain recent complete rows within the application-log byte cap. | +| `delete_previous` | `false` | Boolean | Explicitly remove the current application log when the logger starts. | +| `retention_days` | `30` | Integer from 0 through 3650 | Remove expired rotated `Logicytics*.log` files; does not remove run evidence. | + +Engine and collector JSONL event streams are always run-owned and separate from +the human application log. All channels redact sensitive fields before writing. + +## Shipped collector settings + +The engine validates the following core fields before planning. A collector also +revalidates settings inside its isolated worker. Unknown fields for these shipped +collectors are rejected. + +| Collector | Setting | Default | Accepted value | +|--------------------------------------------|----------------------------|---------------------------|-----------------------------------------------------------| +| `core.network.bandwidth_sample` | `sample_count` | `3` | Integer 1-10. | +| `core.network.bandwidth_sample` | `interval_seconds` | `1.0` | Finite number 0.1-60 seconds. | +| `core.packet.packet_capture` | `packet_count` | `100` | Integer 1-10000. | +| `core.packet.packet_capture` | `timeout_seconds` | `10` | Finite number 1-60 seconds. | +| `core.packet.packet_capture` | `retry_window_seconds` | `0` | Finite number 0-60 seconds. | +| `core.packet.packet_capture` | `interface` | local host IPv4 address | Non-empty text without a NUL byte. | +| `core.filesystem.system_drive_tree` | `max_entries` | `5000` | Integer 1-50000. | +| `core.filesystem.system_drive_tree` | `max_depth` | `12` | Integer 1-32. | +| `core.filesystem.system_drive_listing` | `max_entries` | `10000` | Integer 1-50000. | +| `core.filesystem.system_drive_listing` | `max_depth` | `16` | Integer 1-32. | +| `core.filesystem.sensitive_file_inventory` | `root` | Windows system-drive root | Non-empty absolute filesystem path. | +| `core.filesystem.sensitive_file_inventory` | `max_directories` | `5000` | Integer 1-50000. | +| `core.filesystem.sensitive_file_inventory` | `max_matches` | `500` | Integer 1-5000. | +| `core.process.memory_map` | `max_regions` | `5000` | Integer 1-100000. | +| `core.process.memory_map` | `output_limit_bytes` | `67108864` (64 MiB) | Integer 1024-67108864. | +| `core.process.memory_map` | `disk_safety_margin_bytes` | `104857600` (100 MiB) | Integer 0-68719476736. | +| `core.process.memory_map` | `dump_directory` | `memory_maps` | Non-empty relative collector-workspace path without `..`. | + +Shipped core collectors without a table above do not declare configurable fields, +so a non-empty settings object for one is rejected. Syntactically valid plugin- +and MOD-owned fields remain extensible for their extension contracts, but they do +not bypass capability, workspace, artifact, timeout, or output limits. + +## Schema 3 and INI migration + +Schema-version `3` JSON is translated in memory and recorded as +`migrated_from_schema: 3` in the run manifest. Root aliases `workers` and +`worker_count` become `runtime.default_max_workers`; `max_workers` becomes +`runtime.maximum_workers`; `output_root`, `package_completed_runs`, and +`maximum_run_output_bytes` move under `runtime`; and `collector_settings` becomes +`collectors`. Historical `ACCESS/RUNS` resolves to `output/data`. Conflicting old +and new names fail closed instead of choosing one silently. + +The read-only `CODE/config.ini` bridge translates these historical fields: + +- `Settings`: `max_workers`, `log_using_debug`, `delete_old_logs`, and + `save_preferences`. +- `Flag Settings`: `accuracy_min`, `model_to_use`, and `model_debug`. +- `DumpMemory Settings`: `file_size_limit` and `file_size_safety`. +- `NetWorkPsutil Settings`: `sample_count` and `interval`. +- `PacketSniffer Settings`: `interface`, `packet_count`, `timeout`, and + `max_retry_time`. + +Use a schema-v4 JSON file for all new configuration. See [MIGRATION.md](MIGRATION.md) +for the broader v3-to-v4 runtime and extension compatibility boundary. + +## Validation and inspection + +Validate discovery, configuration, and host prerequisites without collecting: + +```powershell +python -m logicytics --config .\logicytics.json preflight +python -m logicytics --config .\logicytics.json plan --profile standard +``` + +The run manifest records a redacted configuration snapshot and deterministic +configuration fingerprint. Use `query_run` to inspect the resolved plan, +collector results, artifacts, failures, and hashes after a run. diff --git a/docs/FEATURE_STATUS.md b/docs/FEATURE_STATUS.md new file mode 100644 index 00000000..686aa7eb --- /dev/null +++ b/docs/FEATURE_STATUS.md @@ -0,0 +1,70 @@ +# v4 feature ownership and status + +This register maps every section in [TODO.md](TODO.md) to its implementation +owner, current status, and primary evidence. Every checklist item inherits the +owner of its nearest heading; the checkbox beside that item is the authoritative +item-level status. The separate GitHub wiki was synchronized for v4.0 in wiki +commit `eaf7628`. + +| TODO section | Owner | Status | Primary evidence | +|-------------------------------------------------------|----------------------------------------|----------|-------------------------------------------------------------------------------| +| Core product redesign — mandatory v4.0 change | Architecture maintainers | Complete | `logicytics/contracts.py`, `planner.py`, `runtime.py`, `packaging.py` | +| Core functionality status | Runtime maintainers | Complete | `tests/test_core_functionality.py`, `tests/test_flow_matrix.py` | +| Core and plugin layout — canonical v4.0 model | Discovery maintainers | Complete | `core/`, `plugins/`, `logicytics/discovery.py` | +| Strict collector file, class, and specialty standards | Collector contract maintainers | Complete | `logicytics/contracts.py`, `discovery.py`, `tests/test_shipped_collectors.py` | +| File and folder rules | Discovery maintainers | Complete | `logicytics/discovery.py`, static preflight tests | +| Class and identity rules | Collector contract maintainers | Complete | `CollectorMetadata`, strict preflight tests | +| Specialty rules | Collector contract maintainers | Complete | `Specialty`, shipped collector catalog tests | +| Required class interface | Collector contract maintainers | Complete | `CoreCollector`, `PluginCollector`, lifecycle tests | +| Static and runtime preflight validation | Discovery maintainers | Complete | `logicytics/discovery.py`, `validation_worker.py` | +| Per-collector sandbox and failure isolation | Runtime maintainers | Complete | `logicytics/runtime.py`, cancellation/isolation tests | +| Replace the current architectural model | Architecture maintainers | Complete | v4 packages and `MIGRATION.md` | +| Define the v4.0 run model | Runtime and manifest maintainers | Complete | `runtime.py`, `manifest.py`, package integration tests | +| Separate responsibilities into layers | Architecture maintainers | Complete | CLI, planner, runtime, adapters, artifacts, packaging, maintenance modules | +| Make collection safe, bounded, and observable | Runtime and security maintainers | Complete | capability metadata, worker limits, progress/failure tests | +| Make concurrency a planned capability | Planner/runtime maintainers | Complete | resource scheduler and concurrency tests | +| Make configuration and profiles understandable | Configuration/planner maintainers | Complete | `CONFIGURATION.md`, `configuration.py`, profile tests | +| Improve the extension model | Extension maintainers | Complete | `MODS.md`, plugin/MOD discovery and isolation tests | +| Improve packaging and evidence integrity | Artifact/packaging maintainers | Complete | `OUTPUTS.md`, package/hash reproduction tests | +| Improve the public API and testability | API maintainers | Complete | `logicytics/api.py`, public API tests | +| Migration rules from the old design | Compatibility maintainers | Complete | `MIGRATION.md`, migration regression tests | +| v4.0 goals | Release maintainers | Complete | `V4_RELEASE.md` and full release gates | +| 1. Main engine and execution lifecycle | Runtime maintainers | Complete | CLI/planner/runtime integration tests | +| Execution modes | Mode/planner maintainers | Complete | `logicytics/modes.py`, machine-readable mode matrix tests | +| Post-run actions | Runtime maintainers | Complete | typed shutdown/reboot package-gating tests | +| 2. Command-line and interaction features | CLI/interaction maintainers | Complete | `cli.py`, `interaction.py`, usage/matching tests | +| 3. Core Logicytics engine/library API | API maintainers | Complete | public lazy API and integration tests | +| Command execution | Platform adapter maintainers | Complete | `ProcessAdapter`, command/MOD tests | +| File discovery | Discovery maintainers | Complete | `file_listing.py`, recursive discovery tests | +| Environment checks | Platform adapter maintainers | Complete | `environment.py`, adapter and live Windows tests | +| Configuration | Configuration maintainers | Complete | `CONFIGURATION.md`, parser/migration tests | +| Logging | Logging maintainers | Complete | `logging.py`, singleton/redaction/retention tests | +| Output and packaging | Artifact/packaging maintainers | Complete | `artifacts.py`, `packaging.py`, `OUTPUTS.md` | +| 4. Standard system-data collectors | Core collector maintainers | Complete | 66-collector preflight, lifecycle, output, and Windows tests | +| System, hardware, and operating-system inventory | System/hardware collector maintainers | Complete | `core/system/`, `core/hardware/` | +| Network inventory and activity | Network/packet collector maintainers | Complete | `core/network/`, `core/packet/` | +| Wireless, Bluetooth, and removable-device history | Wireless/device collector maintainers | Complete | `core/wireless/`, `core/bluetooth/`, `core/usb/` | +| Windows logs, registry, storage, and encryption | Windows evidence collector maintainers | Complete | `core/event_log/`, `registry/`, `storage/`, `encryption/` | +| Memory and process diagnostics | Process/memory collector maintainers | Complete | `core/process/`, `core/memory/` | +| User files and media | File evidence collector maintainers | Complete | `core/browser/`, `filesystem/`, `media/`, `ssh/` | +| Sysinternals integration | Diagnostics collector maintainers | Complete | `core/diagnostics/sysinternals_report.py`, `sysinternals.py` | +| 5. Maintenance and developer features | Maintenance maintainers | Complete | `maintenance.py`, CLI maintenance flow tests | +| Debugger (`--debug`) | Maintenance maintainers | Complete | debug diagnostics tests and `FLOW_MATRIX.md` | +| Update action (`--update`) | Maintenance maintainers | Complete | authenticated/read-only check and explicit apply tests | +| Developer action (`--dev`) | Maintenance maintainers | Complete | integrity/version/developer check tests | +| 6. MODS extension system | Extension maintainers | Complete | `MODS.md`, MOD runner and package tests | +| 7. Configuration and output contract | Configuration/artifact maintainers | Complete | `CONFIGURATION.md`, `OUTPUTS.md`, contract tests | +| 8. v4.0 delivery phases | Release maintainers | Complete | phases 1-5 complete, including wiki commit `eaf7628` | +| Phase 1 — Foundation | Architecture maintainers | Complete | contracts, configuration, logging, adapters, run workspace | +| Phase 2 — Engine and packaging | Runtime/packaging maintainers | Complete | scheduling, modes, ZIP/hash, MOD packaging tests | +| Phase 3 — Core collectors | Core collector maintainers | Complete | all 66 core collectors pass strict preflight | +| Phase 4 — Deep collection and visualization | Deep-collection maintainers | Complete | deep profile, packet graph, usage SVG tests | +| Phase 5 — Maintenance and release readiness | Release maintainers | Complete | repository release gates and wiki commit `eaf7628` | +| 9. v4.0 completion checklist | Release maintainers | Complete | all repository and wiki checklist items verified | + +## Maintenance rule + +Any new TODO heading must be added to this register in the same commit. Any item +whose owner, status, or evidence changes must update this table and the relevant +versioned contract document. The documentation contract test enforces exact +heading coverage so features cannot become ownerless. diff --git a/docs/FLOW_MATRIX.md b/docs/FLOW_MATRIX.md new file mode 100644 index 00000000..4b36f89a --- /dev/null +++ b/docs/FLOW_MATRIX.md @@ -0,0 +1,28 @@ +# v4 execution-flow verification matrix + +This matrix names the executable regression evidence for every supported v4 +mode, maintenance action, post-run action, and terminal safety flow. The test +suite verifies that every referenced test remains present and then executes the +tests normally. + +| Flow | Executable evidence | +|---------------------|----------------------------------------------------------------------------| +| default / standard | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | +| threaded / balanced | `test_explicit_execution_modes_control_isolated_worker_overlap` | +| minimal / quick | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | +| deep / thorough | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | +| non-Python | `test_nopy_and_modded_modes_select_declared_mod_types_without_helpers` | +| performance | `test_performance_report_is_finalized_before_automatic_packaging` | +| modded / extensions | `test_mods_require_sidecars_and_run_as_isolated_registered_artifacts` | +| debug | `test_dev_writes_explicit_manifest_and_debug_persists_diagnostics` | +| update | `test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window` | +| usage | `test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in` | +| shutdown and reboot | `test_post_run_actions_are_typed_exclusive_and_require_verified_packaging` | +| cancellation | `test_cancelled_run_writes_a_recoverable_package_and_manifest` | +| permission denied | `test_access_denied_is_skipped_not_failed` | + +The mode registry test covers named modes and every historical flag alias. The +overlap test executes real isolated workers. MOD and non-Python tests exercise +sidecar discovery and native adapters. Maintenance tests keep update/debug out of +normal collection. Power actions require a verified package. Cancellation and +permission tests prove these states cannot be misreported as success. diff --git a/MIGRATION.md b/docs/MIGRATION.md similarity index 58% rename from MIGRATION.md rename to docs/MIGRATION.md index 6cf023ba..e9347445 100644 --- a/MIGRATION.md +++ b/docs/MIGRATION.md @@ -10,19 +10,19 @@ model, but it may not create a second execution or output pipeline. These bridges are intentionally supported for the v4 release and are not internal shims: -| Historical interface | v4 replacement | Retention policy | -| --- | --- | --- | -| `--default` | `--mode standard` | Supported v4 CLI alias | -| `--threaded` | `--mode balanced` | Supported v4 CLI alias | -| `--minimal` | `--mode quick` | Supported v4 CLI alias | -| `--depth` | `--mode thorough` | Supported v4 CLI alias | -| `--nopy` | `--mode non-python` | Supported v4 CLI alias | -| `--modded` | `--mode extensions` | Supported v4 CLI alias | -| `--performance-check` | `--mode performance` | Supported v4 CLI alias | -| schema-3 JSON settings | schema-4 `logicytics.json` | Read and migrated in memory | -| `CODE/config.ini` | schema-4 `logicytics.json` | Read-only fallback when JSON is absent | -| generated evidence in `CODE/` | `core.integration.legacy_code_outputs` | Deep-profile import only | -| `.py`, `.ps1`, `.bat`, `.exe` files in `MODS/` | typed plugin collector | Isolated compatibility adapter | +| Historical interface | v4 replacement | Retention policy | +|------------------------------------------------|----------------------------------------|----------------------------------------| +| `--default` | `--mode standard` | Supported v4 CLI alias | +| `--threaded` | `--mode balanced` | Supported v4 CLI alias | +| `--minimal` | `--mode quick` | Supported v4 CLI alias | +| `--depth` | `--mode thorough` | Supported v4 CLI alias | +| `--nopy` | `--mode non-python` | Supported v4 CLI alias | +| `--modded` | `--mode extensions` | Supported v4 CLI alias | +| `--performance-check` | `--mode performance` | Supported v4 CLI alias | +| schema-3 JSON settings | schema-4 `logicytics.json` | Read and migrated in memory | +| `CODE/config.ini` | schema-4 `logicytics.json` | Read-only fallback when JSON is absent | +| generated evidence in `CODE/` | `core.integration.legacy_code_outputs` | Deep-profile import only | +| `.py`, `.ps1`, `.bat`, `.exe` files in `MODS/` | typed plugin collector | Isolated compatibility adapter | Legacy inputs never enable plugins or MODS implicitly. Ambiguous settings, duplicate keys, unsafe paths, undeclared capabilities, and unsupported fields diff --git a/MODS.md b/docs/MODS.md similarity index 100% rename from MODS.md rename to docs/MODS.md diff --git a/OUTPUTS.md b/docs/OUTPUTS.md similarity index 62% rename from OUTPUTS.md rename to docs/OUTPUTS.md index 1836e5c0..3e254224 100644 --- a/OUTPUTS.md +++ b/docs/OUTPUTS.md @@ -15,30 +15,30 @@ for copied source evidence and `derived` for generated reports. The suffix is fixed by the declared MIME type: -| MIME type | Suffix | -| --- | --- | -| `application/json` | `.json` | -| `application/xml` | `.xml` | -| `application/zip` | `.zip` | -| `text/csv` | `.csv` | -| `text/html` | `.html` | -| `text/plain` | `.txt` | -| `text/vnd.graphviz` | `.dot` | +| MIME type | Suffix | +|---------------------|---------| +| `application/json` | `.json` | +| `application/xml` | `.xml` | +| `application/zip` | `.zip` | +| `text/csv` | `.csv` | +| `text/html` | `.html` | +| `text/plain` | `.txt` | +| `text/vnd.graphviz` | `.dot` | This rule covers every shipped collector except the explicitly listed contracts below. MIME types remain those declared by each collector's `CollectorMetadata`. -| Collector | Stable workspace path pattern | -| --- | --- | -| `core.bluetooth.paired_devices` | `bluetooth_devices.json` | -| `core.browser.browser_data_backup` | `browser_data/*/*/*` | -| `core.filesystem.sensitive_file_inventory` | `sensitive_file_inventory/**` | -| `core.integration.legacy_code_outputs` | `legacy_code/**` | -| `core.media.media_backup` | `media_backup/**` | -| `core.process.memory_map` | `**/memory_map.json` | -| `core.registry.hklm_backup` | `hklm_backup.reg` | -| `core.system.windows_system_data_backup` | `windows_system_data/*/*` | -| `core.wireless.wifi_profile_keys` | `wifi_profiles_with_keys/*.xml` | +| Collector | Stable workspace path pattern | +|--------------------------------------------|---------------------------------| +| `core.bluetooth.paired_devices` | `bluetooth_devices.json` | +| `core.browser.browser_data_backup` | `browser_data/*/*/*` | +| `core.filesystem.sensitive_file_inventory` | `sensitive_file_inventory/**` | +| `core.integration.legacy_code_outputs` | `legacy_code/**` | +| `core.media.media_backup` | `media_backup/**` | +| `core.process.memory_map` | `**/memory_map.json` | +| `core.registry.hklm_backup` | `hklm_backup.reg` | +| `core.system.windows_system_data_backup` | `windows_system_data/*/*` | +| `core.wireless.wifi_profile_keys` | `wifi_profiles_with_keys/*.xml` | `core.bluetooth.bluetooth_history` uses the fixed `bluetooth_history.json` name. Its content carries `collected_at`; the run ID and diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..3a9961c4 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +# About docs + +Please understand this is NOT the projects docs, but rather the v3 → v4 system change docs that were used in development + +This folder may be removed/replaced in the future + +The real projects docs are found in the Repo's Wiki \ No newline at end of file diff --git a/TODO.md b/docs/TODO.md similarity index 99% rename from TODO.md rename to docs/TODO.md index 157271c7..351c21fe 100644 --- a/TODO.md +++ b/docs/TODO.md @@ -22,7 +22,7 @@ collector is launched. - [x] Typed collector contracts, metadata, statuses, run requests, validation, and artifact interfaces. - [x] Typed local configuration with validated worker and output limits. -- [x] Strict discovery/preflight for `core/` and `plugins/`, including filename, +- [x] Strict discovery/preflight for `../core` and `../plugins`, including filename, class, inheritance, method, docstring, annotation, metadata, specialty, and ID validation. - [x] Short-lived isolated metadata probe and invalid-plugin quarantine policy. @@ -176,11 +176,11 @@ Logicytics/ └── logicytics/ # engine, contracts, planner, runtime ``` -- [x] Make `core/` the only source of shipped collection functionality. -- [x] Automatically discover and validate every eligible collector in `core/` at +- [x] Make `../core` the only source of shipped collection functionality. +- [x] Automatically discover and validate every eligible collector in `../core` at startup; core collectors are included by the selected built-in profile unless a profile explicitly excludes them. -- [x] Make `plugins/` an opt-in user extension area. Discover plugins automatically +- [x] Make `../plugins` an opt-in user extension area. Discover plugins automatically during preflight, but only run valid plugins when the user selects the plugin profile/flag or explicitly includes them. - [x] Permit plugins to implement any useful collection specialty, including new @@ -189,7 +189,7 @@ Logicytics/ - [x] Keep plugin output, logs, permissions, and failures separated from core output while still including both in the final run manifest when selected. - [x] Never silently execute arbitrary `.py` files merely because they exist in - `core/` or `plugins/`. + `../core` or `../plugins`. - [x] Ignore caches, tests, examples, private helpers, virtual environments, and files beginning with `_` during collector discovery. - [x] Require an explicit `plugin_id` and detect duplicate IDs before any collector @@ -572,7 +572,7 @@ Windows platform allows. - [x] Performs the requested shutdown or reboot action at the end. - [x] Handles Ctrl+C gracefully and attempts final packaging/cleanup. - [x] Offers a final exit prompt for interactive use. -- [x] Discover runnable files recursively from canonical `core/`, `plugins/`, and +- [x] Discover runnable files recursively from canonical `../core`, `../plugins`, and `MODS/` directories; the legacy CODE directory is intentionally retired. - [x] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. - [x] Exclude engine/library directories and underscore-prefixed helper files from @@ -933,7 +933,7 @@ appropriate default/deep/non-Python mode. - [x] Every mode has an explicit collector inclusion matrix. - [x] Every output has a documented path, format, and retention rule. - [x] A failed collector cannot silently make the run appear successful. -- [x] A cancelled run leaves a recoverable partial-run report and cleans only its +- [x] A canceled run leaves a recoverable partial-run report and cleans only its own temporary data. - [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. - [x] Debug/update/developer tools are separated from normal collection behavior. diff --git a/V4_RELEASE.md b/docs/V4_RELEASE.md similarity index 96% rename from V4_RELEASE.md rename to docs/V4_RELEASE.md index 482003e8..1a1995a6 100644 --- a/V4_RELEASE.md +++ b/docs/V4_RELEASE.md @@ -131,6 +131,6 @@ authoritative item-level completion record. ## Upgrade and support Read [MIGRATION.md](MIGRATION.md) before reusing a v3 configuration, legacy flag, -MOD, or generated `CODE` output. Read [README.md](README.md) for installation and -operation, [CONTRIBUTING.md](CONTRIBUTING.md) for change requirements, and -[SECURITY.md](SECURITY.md) for supported versions and vulnerability reporting. +MOD, or generated `CODE` output. Read [README.md](../README.md) for installation and +operation, [CONTRIBUTING.md](../CONTRIBUTING.md) for change requirements, and +[SECURITY.md](../SECURITY.md) for supported versions and vulnerability reporting. From f397234469d8044b3399755e677dbc4792529586 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:16:04 +0300 Subject: [PATCH 215/316] chore: reformatted code --- core/bluetooth/bluetooth_addresses.py | 4 +- core/bluetooth/bluetooth_history.py | 4 +- core/bluetooth/paired_devices.py | 4 +- core/browser/browser_data_backup.py | 10 +- core/diagnostics/sysinternals_report.py | 2 +- core/encryption/bitlocker_status.py | 5 +- core/encryption/bitlocker_volumes.py | 4 +- core/event_log/application_events.py | 5 +- core/event_log/security_events.py | 5 +- core/event_log/system_events.py | 5 +- core/filesystem/sensitive_file_inventory.py | 3 +- core/filesystem/startup_folder_entries.py | 6 +- core/filesystem/system_drive_tree.py | 2 +- core/hardware/battery_status.py | 4 +- core/hardware/display_adapters.py | 7 +- core/hardware/windows_features.py | 4 +- core/integration/legacy_code_outputs.py | 10 +- core/media/media_backup.py | 3 +- core/network/active_connections.py | 5 +- core/network/adapter_statistics.py | 4 +- core/network/arp_cache.py | 5 +- core/network/bandwidth_sample.py | 9 +- core/network/connection_processes.py | 4 +- core/network/dns_cache.py | 5 +- core/network/firewall_profiles.py | 4 +- core/network/network_adapters.py | 5 +- core/network/network_interfaces.py | 4 +- core/network/routing_table.py | 5 +- core/packet/connection_graph.py | 5 +- core/packet/packet_capture.py | 3 +- core/process/detailed_processes.py | 5 +- core/process/process_memory.py | 4 +- core/process/running_processes.py | 5 +- core/registry/hklm_backup.py | 6 +- core/ssh/ssh_backup.py | 4 +- core/storage/mounted_volumes.py | 5 +- core/storage/physical_disks.py | 4 +- core/system/bios_info.py | 4 +- core/system/computer_system.py | 4 +- core/system/defender_status.py | 18 +-- core/system/environment_posture.py | 4 +- core/system/group_policy.py | 5 +- core/system/installed_drivers.py | 5 +- core/system/installed_updates.py | 4 +- core/system/local_accounts.py | 7 +- core/system/operating_system.py | 4 +- core/system/scheduled_tasks.py | 4 +- core/system/session_snapshot.py | 4 +- core/system/system_details.py | 5 +- core/system/system_diagnostics.py | 4 +- core/system/windows_services.py | 4 +- core/system/windows_system_data_backup.py | 3 +- core/wireless/wifi_interfaces.py | 4 +- core/wireless/wifi_profile_keys.py | 7 +- core/wireless/wifi_profiles.py | 5 +- logicytics/__init__.py | 3 +- logicytics/api.py | 8 +- logicytics/cli.py | 35 +++-- logicytics/configuration.py | 46 +++--- logicytics/contracts.py | 7 +- logicytics/discovery.py | 20 +-- logicytics/environment.py | 4 +- logicytics/interaction.py | 11 +- logicytics/logging.py | 24 ++-- logicytics/maintenance.py | 45 +++--- logicytics/manifest.py | 2 +- logicytics/modes.py | 6 +- logicytics/output_layout.py | 12 +- logicytics/planner.py | 8 +- logicytics/platform_adapters.py | 10 +- logicytics/runtime.py | 31 ++-- tests/golden/bios_info.html | 50 +++++-- tests/golden/manifest.json | 12 +- tests/test_collector_cancellation.py | 8 +- tests/test_core_functionality.py | 150 +++++++++++--------- tests/test_platform_adapters.py | 8 +- tests/test_release_documentation.py | 22 +-- tests/test_running_processes_collector.py | 6 +- tests/test_shipped_collectors.py | 14 +- tests/test_windows_integration.py | 4 +- 80 files changed, 436 insertions(+), 383 deletions(-) diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index 42346db3..b001d5fc 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -4,11 +4,11 @@ import json import re -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index dcb8eeb5..bdec8961 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -3,12 +3,12 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py index 3c29a124..ef547257 100644 --- a/core/bluetooth/paired_devices.py +++ b/core/bluetooth/paired_devices.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import ( Capability, @@ -15,6 +13,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index 651251e6..5ac40510 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -4,7 +4,8 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -69,10 +70,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("browser_data_backup_started") for browser, root in chromium_roots: try: - profile_roots = [root] if browser.startswith("opera") else [path for path in filesystem_adapter.children(root) if + profile_roots = [root] if browser.startswith("opera") else [path for path in + filesystem_adapter.children(root) if path.is_dir() and ( - path.name == "Default" or path.name.startswith( - "Profile "))] + path.name == "Default" or path.name.startswith( + "Profile "))] except OSError: continue for profile in profile_roots: diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py index c36e0a87..68b978c4 100644 --- a/core/diagnostics/sysinternals_report.py +++ b/core/diagnostics/sysinternals_report.py @@ -3,11 +3,11 @@ from __future__ import annotations import os -from logicytics.platform_adapters import process_adapter as subprocess from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess TOOLS = ("psfile", "psgetsid", "psinfo", "pslist", "psloggedon", "psloglist") MAX_OUTPUT_CHARS = 512_000 diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index 1ebc78ed..7be31cea 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str, return_code: int) -> bool: diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 1a0c903d..4d35c0ee 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -5,13 +5,13 @@ import getpass import json import platform -from logicytics.platform_adapters import process_adapter as subprocess from datetime import datetime, timezone -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import network_adapter as socket, windows_api_adapter +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 09ebfb0a..1b38258d 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -15,6 +12,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which _MAX_EVENTS = 1_000 diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index e611556f..91cf71ea 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -15,6 +12,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which _MAX_EVENTS = 1_000 diff --git a/core/event_log/system_events.py b/core/event_log/system_events.py index f51e1164..ea3b24b2 100644 --- a/core/event_log/system_events.py +++ b/core/event_log/system_events.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -15,6 +12,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which _MAX_EVENTS = 1_000 diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index 70a5e2f1..42f15f1a 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -4,7 +4,8 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter diff --git a/core/filesystem/startup_folder_entries.py b/core/filesystem/startup_folder_entries.py index f2c82819..283bd54f 100644 --- a/core/filesystem/startup_folder_entries.py +++ b/core/filesystem/startup_folder_entries.py @@ -69,7 +69,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: children = sorted(filesystem_adapter.children(folder), key=lambda path: path.name.casefold()) except OSError as error: - return CollectorResult(CollectorStatus.SKIPPED, "Startup-folder access was denied", errors=(str(error),)) + return CollectorResult(CollectorStatus.SKIPPED, "Startup-folder access was denied", + errors=(str(error),)) for child in children: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Startup-folder collection") @@ -90,7 +91,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: output.write_text(json.dumps({"entries": entries, "missing_folders": missing_folders}, indent=2) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("startup_folder_entries_finished", entry_count=len(entries), bytes_written=artifact.size_bytes) + context.report_progress("startup_folder_entries_finished", entry_count=len(entries), + bytes_written=artifact.size_bytes) return CollectorResult.succeeded("Startup-folder entries collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py index 0ba74c3d..8c0cf0ba 100644 --- a/core/filesystem/system_drive_tree.py +++ b/core/filesystem/system_drive_tree.py @@ -59,7 +59,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: truncated = False context.report_progress("system_drive_tree_started", max_entries=maximum_entries, max_depth=maximum_depth) for current, directories, filenames in filesystem_adapter.walk(root, topdown=True, followlinks=False, - onerror=lambda _error: None): + onerror=lambda _error: None): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive tree collection") relative = Path(current).relative_to(root) diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index e388bcd6..ef3be251 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py index 17117282..3966fc8a 100644 --- a/core/hardware/display_adapters.py +++ b/core/hardware/display_adapters.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which class DisplayAdaptersCollector(CoreCollector): @@ -52,7 +52,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: adapters = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned invalid JSON", + errors=(str(error),)) if not isinstance(adapters, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned an unexpected result") output = context.workspace / "display_adapters.json" diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index c36ebb6d..7a539f67 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import ( Capability, @@ -15,6 +13,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py index 3c485fef..0be890f6 100644 --- a/core/integration/legacy_code_outputs.py +++ b/core/integration/legacy_code_outputs.py @@ -80,11 +80,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: continue try: if ( - not path.is_file() - or path.is_symlink() - or path.resolve().parent != (code_root / relative.parent).resolve() - or path.suffix.casefold() not in _EVIDENCE_EXTENSIONS - or path.stat().st_size > _MAXIMUM_FILE_BYTES + not path.is_file() + or path.is_symlink() + or path.resolve().parent != (code_root / relative.parent).resolve() + or path.suffix.casefold() not in _EVIDENCE_EXTENSIONS + or path.stat().st_size > _MAXIMUM_FILE_BYTES ): continue except OSError: diff --git a/core/media/media_backup.py b/core/media/media_backup.py index f357e312..3271a6eb 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -5,7 +5,8 @@ from datetime import datetime, timezone from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter diff --git a/core/network/active_connections.py b/core/network/active_connections.py index 0742415a..ed47ba0e 100644 --- a/core/network/active_connections.py +++ b/core/network/active_connections.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py index 62503bea..5ad51667 100644 --- a/core/network/adapter_statistics.py +++ b/core/network/adapter_statistics.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/arp_cache.py b/core/network/arp_cache.py index 4c23064f..d69d92d9 100644 --- a/core/network/arp_cache.py +++ b/core/network/arp_cache.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index 74521383..fd4ffd74 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -3,12 +3,12 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess import time -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which _DEFAULT_SAMPLES = 3 _DEFAULT_INTERVAL_SECONDS = 1 @@ -22,7 +22,8 @@ def _setting(settings: object, key: str, default: int, maximum: int) -> int: def _interval_setting(settings: object) -> float: """Return the configured finite positive sampling interval.""" - value = settings.get("interval_seconds", _DEFAULT_INTERVAL_SECONDS) if isinstance(settings, dict) else _DEFAULT_INTERVAL_SECONDS + value = settings.get("interval_seconds", _DEFAULT_INTERVAL_SECONDS) if isinstance(settings, + dict) else _DEFAULT_INTERVAL_SECONDS if isinstance(value, (int, float)) and not isinstance(value, bool) and 0.1 <= value <= 60: return float(value) return float(_DEFAULT_INTERVAL_SECONDS) @@ -90,7 +91,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned an unexpected result") observations.append({str(record.get("Name", "unavailable")): { "received": int(record.get("ReceivedBytes", 0)), "sent": int(record.get("SentBytes", 0))} for record in - records}) + records}) if index + 1 < samples: time.sleep(interval) rates: dict[str, dict[str, float]] = {} diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py index 73afbb7b..b26c0f2e 100644 --- a/core/network/connection_processes.py +++ b/core/network/connection_processes.py @@ -4,11 +4,11 @@ import csv import io -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py index a39aabad..4f187105 100644 --- a/core/network/dns_cache.py +++ b/core/network/dns_cache.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index 2f2ff7d8..064201e9 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py index 54963b86..0a8a2781 100644 --- a/core/network/network_adapters.py +++ b/core/network/network_adapters.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which class NetworkAdaptersCollector(CoreCollector): diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index 4695191e..e32d47ba 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -4,11 +4,11 @@ import ipaddress import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/network/routing_table.py b/core/network/routing_table.py index c2458ce7..df8faf67 100644 --- a/core/network/routing_table.py +++ b/core/network/routing_table.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index e4043268..94c36c8a 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index 50405748..889fb563 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -67,7 +67,8 @@ def validate(self, context: CollectorContext) -> ValidationResult: timeout = float(context.settings.get("timeout_seconds", 10)) retry_window = float(context.settings.get("retry_window_seconds", 0)) except (TypeError, ValueError): - return ValidationResult(False, reasons=("packet_count, timeout_seconds, and retry_window_seconds must be numeric",)) + return ValidationResult(False, reasons=( + "packet_count, timeout_seconds, and retry_window_seconds must be numeric",)) if not 1 <= count <= 10_000 or not 1 <= timeout <= 60 or not 0 <= retry_window <= 60: return ValidationResult( False, diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py index f49806fd..887ba38d 100644 --- a/core/process/detailed_processes.py +++ b/core/process/detailed_processes.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/process/process_memory.py b/core/process/process_memory.py index 1e81c3aa..0dbccca3 100644 --- a/core/process/process_memory.py +++ b/core/process/process_memory.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/process/running_processes.py b/core/process/running_processes.py index df9e5210..5b62e08d 100644 --- a/core/process/running_processes.py +++ b/core/process/running_processes.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which class RunningProcessesCollector(CoreCollector): diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index 2db33c52..7c8f9733 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -2,12 +2,12 @@ from __future__ import annotations +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult +from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult -from logicytics.contracts import CollectorContext, CollectorStatus - def _is_access_denied(detail: str) -> bool: """Recognize Windows permission-denied or elevation-required wording.""" diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index feea4587..2a53eaef 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -3,9 +3,9 @@ from __future__ import annotations import zipfile -from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index 9c494f85..8e358468 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index 96bfcf4e..7d51626a 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/bios_info.py b/core/system/bios_info.py index 0250aa33..3eef110a 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -4,8 +4,6 @@ import html import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import ( Capability, @@ -16,6 +14,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/computer_system.py b/core/system/computer_system.py index 6064b3df..db5e49cc 100644 --- a/core/system/computer_system.py +++ b/core/system/computer_system.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import ( Capability, @@ -15,6 +13,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/defender_status.py b/core/system/defender_status.py index 00228556..25791215 100644 --- a/core/system/defender_status.py +++ b/core/system/defender_status.py @@ -3,21 +3,21 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_unavailable(detail: str) -> bool: """Recognize missing Defender-provider and access-denied results without failing a run.""" normalized = detail.casefold() return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "not recognized" in normalized - or "cannot find" in normalized + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "not recognized" in normalized + or "cannot find" in normalized ) @@ -72,12 +72,14 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_unavailable(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Microsoft Defender status is unavailable", errors=(detail,)) + return CollectorResult(CollectorStatus.SKIPPED, "Microsoft Defender status is unavailable", + errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "Microsoft Defender status query failed", errors=(detail,)) try: status = json.loads(completed.stdout) if completed.stdout.strip() else {} except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Defender status query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "Defender status query returned invalid JSON", + errors=(str(error),)) if not isinstance(status, dict): return CollectorResult(CollectorStatus.FAILED, "Defender status query returned an unexpected result") output = context.workspace / "defender_status.json" diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index b854974b..ea8370a2 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/group_policy.py b/core/system/group_policy.py index 7ed2cb31..0d25ac9c 100644 --- a/core/system/group_policy.py +++ b/core/system/group_policy.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index 0c49c7ec..a6747241 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index ebe2c983..31cfd6c7 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/local_accounts.py b/core/system/local_accounts.py index c2900f06..87f9518b 100644 --- a/core/system/local_accounts.py +++ b/core/system/local_accounts.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: @@ -70,7 +70,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: accounts = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "local-account query returned invalid JSON", errors=(str(error),)) + return CollectorResult(CollectorStatus.FAILED, "local-account query returned invalid JSON", + errors=(str(error),)) if not isinstance(accounts, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "local-account query returned an unexpected result") output = context.workspace / "local_accounts.json" diff --git a/core/system/operating_system.py b/core/system/operating_system.py index 2bf25438..0d6d71b5 100644 --- a/core/system/operating_system.py +++ b/core/system/operating_system.py @@ -3,8 +3,6 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import ( Capability, @@ -15,6 +13,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/scheduled_tasks.py b/core/system/scheduled_tasks.py index b435c21e..760b275f 100644 --- a/core/system/scheduled_tasks.py +++ b/core/system/scheduled_tasks.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py index a137de71..54a56245 100644 --- a/core/system/session_snapshot.py +++ b/core/system/session_snapshot.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/system_details.py b/core/system/system_details.py index 94a5e01d..a17db847 100644 --- a/core/system/system_details.py +++ b/core/system/system_details.py @@ -2,9 +2,6 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import ( Capability, CollectorMetadata, @@ -14,6 +11,8 @@ ValidationResult, ) from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index 99f25bdd..7b676e15 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/windows_services.py b/core/system/windows_services.py index c21b8cf4..7d81b5d0 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index 2be8ae46..d71d8091 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -4,7 +4,8 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py index 400d587a..d8965f05 100644 --- a/core/wireless/wifi_interfaces.py +++ b/core/wireless/wifi_interfaces.py @@ -3,11 +3,11 @@ from __future__ import annotations import json -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index b5feb45b..d2b2dc66 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -2,12 +2,13 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess from pathlib import Path -from logicytics.platform_adapters import filesystem_adapter, which -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, ValidationResult +from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ + ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import filesystem_adapter, which +from logicytics.platform_adapters import process_adapter as subprocess def _is_access_denied(detail: str) -> bool: diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py index 6926a613..a66eab16 100644 --- a/core/wireless/wifi_profiles.py +++ b/core/wireless/wifi_profiles.py @@ -2,11 +2,10 @@ from __future__ import annotations -from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which - from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.platform_adapters import process_adapter as subprocess +from logicytics.platform_adapters import which def _is_access_denied(detail: str) -> bool: diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 3766d285..bc6299fe 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -23,7 +23,8 @@ ) _APPLICATION_EXPORTS = frozenset({ - "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "open_artifact", "plan_run", "query_run", "read_artifact", + "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "open_artifact", "plan_run", + "query_run", "read_artifact", "run_collection", }) diff --git a/logicytics/api.py b/logicytics/api.py index 5aaf35a8..6c912215 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -3,8 +3,8 @@ from __future__ import annotations import hashlib -import os import json +import os import re from dataclasses import dataclass from datetime import datetime @@ -205,9 +205,9 @@ def _collector_snapshot(record: dict[str, Any], collector_id: str, status: str) if ( raw_failure.get("collector_id") != collector_id or not all( - isinstance(raw_failure.get(field), str) and raw_failure[field].strip() - for field in ("operation", "platform_error", "remediation") - ) + isinstance(raw_failure.get(field), str) and raw_failure[field].strip() + for field in ("operation", "platform_error", "remediation") + ) or not isinstance(raw_failure.get("retry_safe"), bool) ): raise PlanError("run manifest collector failure contains invalid actionable details") diff --git a/logicytics/cli.py b/logicytics/cli.py index 85c42ba1..d22fb9b4 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -16,7 +16,6 @@ from logicytics.discovery import preflight from logicytics.environment import inspect_environment from logicytics.errors import LogicyticsError -from logicytics.manifest import MANIFEST_SCHEMA_VERSION from logicytics.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph from logicytics.logging import get_application_logger from logicytics.maintenance import ( @@ -29,6 +28,7 @@ write_legacy_ini_manifest, write_local_manifest, ) +from logicytics.manifest import MANIFEST_SCHEMA_VERSION from logicytics.modes import ( EXECUTION_MODES, LEGACY_MODE_ALIASES, @@ -36,8 +36,8 @@ mode_matrix, resolve_execution_mode, ) -from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.output_layout import ensure_output_layout +from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.platform_adapters import process_adapter from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -71,10 +71,10 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: ) raise ValueError(f"{mode.name} mode requires sequential execution") sequential = explicit_sequential or ( - mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL + mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL ) parallel = explicit_parallel or ( - mode is not None and mode.strategy is ExecutionStrategy.PARALLEL + mode is not None and mode.strategy is ExecutionStrategy.PARALLEL ) performance_check = mode.performance_check if mode is not None else False enable_mods = getattr(arguments, "mods", False) or ( @@ -118,11 +118,11 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: parent_run_id = previous["run_id"] selection_only = getattr(arguments, "command", None) == "collector" if selection_only and ( - arguments.include - or arguments.exclude - or arguments.profile is not None - or arguments.plugins - or arguments.mods + arguments.include + or arguments.exclude + or arguments.profile is not None + or arguments.plugins + or arguments.mods ): raise ValueError("collector execution cannot combine its ID with profile or selection flags") includes = (arguments.collector_id,) if selection_only else tuple(arguments.include) @@ -161,9 +161,11 @@ def _parser() -> argparse.ArgumentParser: type=Path, help="Path to a v4 JSON or supported legacy INI configuration file", ) - parser.add_argument("--usage", action="store_true", help="Show local interaction statistics and create a usage graph.") + parser.add_argument("--usage", action="store_true", + help="Show local interaction statistics and create a usage graph.") parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") - parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") + parser.add_argument("--match", metavar="TEXT", + help="Suggest the closest documented action for natural-language input.") subcommands = parser.add_subparsers(dest="command") for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") @@ -229,8 +231,10 @@ def _parser() -> argparse.ArgumentParser: help="Run the standard built-in profile with configured parallel workers.") mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") - mode.add_argument("--modded", action="store_true", help="Run the standard profile plus all valid MODS scripts.") - mode.add_argument("--nopy", action="store_true", help="Run only non-Python PowerShell, batch, and executable MODS scripts.") + mode.add_argument("--modded", action="store_true", + help="Run the standard profile plus all valid MODS scripts.") + mode.add_argument("--nopy", action="store_true", + help="Run only non-Python PowerShell, batch, and executable MODS scripts.") mode.add_argument( "--performance-check", action="store_true", @@ -358,7 +362,7 @@ def _status_marker(label: str, count: int) -> str: def _run_developer_action( - root: Path, configuration: AppConfig, arguments: argparse.Namespace + root: Path, configuration: AppConfig, arguments: argparse.Namespace ) -> int: """Run read-only contribution checks and an explicitly confirmed manifest update.""" settings = configuration.maintenance @@ -461,7 +465,8 @@ def main(argv: list[str] | None = None) -> int: "history_persisted": configuration.interaction.history_enabled, } if configuration.interaction.model_debug: - payload["model_debug"] = {"model_name": match.model_name, "threshold": configuration.interaction.similarity_threshold} + payload["model_debug"] = {"model_name": match.model_name, + "threshold": configuration.interaction.similarity_threshold} print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if match.matched_flag is not None else 1 if arguments.command == "usage": diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 2d0c377d..34ccea3d 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -2,10 +2,10 @@ from __future__ import annotations +import configparser import hashlib import json import re -import configparser from dataclasses import asdict, dataclass, field from math import isfinite from pathlib import Path @@ -283,6 +283,8 @@ def _legacy_ini_payload(path: Path) -> dict[str, Any]: "logging": logging, "collectors": collectors, } + + @dataclass(frozen=True, slots=True) class RuntimeSettings: """Engine-wide limits that apply before a collector is started.""" @@ -480,14 +482,14 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi if (remote_url is None) != (remote_sha256 is None): raise PlanError("remote manifest URL and SHA-256 must be configured together") if remote_url is not None and ( - not isinstance(remote_url, str) - or not remote_url.startswith("https://") - or "\n" in remote_url + not isinstance(remote_url, str) + or not remote_url.startswith("https://") + or "\n" in remote_url ): raise PlanError("remote_manifest_url must be an HTTPS URL") if remote_sha256 is not None and ( - not isinstance(remote_sha256, str) - or re.fullmatch(r"[0-9a-f]{64}", remote_sha256) is None + not isinstance(remote_sha256, str) + or re.fullmatch(r"[0-9a-f]{64}", remote_sha256) is None ): raise PlanError("remote_manifest_sha256 must be a lowercase SHA-256 digest") local_manifest_value = maintenance_raw.get("local_manifest_path", "project.manifest.json") @@ -495,26 +497,26 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError("local_manifest_path must be a non-empty relative path") local_manifest_path = Path(local_manifest_value) if ( - local_manifest_path.is_absolute() - or local_manifest_path.drive - or ".." in local_manifest_path.parts + local_manifest_path.is_absolute() + or local_manifest_path.drive + or ".." in local_manifest_path.parts ): raise PlanError("local_manifest_path must remain inside the project") version_pattern = re.compile(r"^\d+\.\d+$") minimum_python = maintenance_raw.get("minimum_python", "3.11") recommended_python = maintenance_raw.get("recommended_python", "3.11") if ( - not isinstance(minimum_python, str) - or version_pattern.fullmatch(minimum_python) is None + not isinstance(minimum_python, str) + or version_pattern.fullmatch(minimum_python) is None ): raise PlanError("minimum_python must use major.minor form") if ( - not isinstance(recommended_python, str) - or version_pattern.fullmatch(recommended_python) is None + not isinstance(recommended_python, str) + or version_pattern.fullmatch(recommended_python) is None ): raise PlanError("recommended_python must use major.minor form") if tuple(map(int, recommended_python.split("."))) < tuple( - map(int, minimum_python.split(".")) + map(int, minimum_python.split(".")) ): raise PlanError("recommended_python must not be older than minimum_python") @@ -534,22 +536,22 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi file_enabled = logging_raw.get("file_enabled", True) delete_previous = logging_raw.get("delete_previous", False) if not all( - isinstance(value, bool) - for value in (console_enabled, color_enabled, file_enabled, delete_previous) + isinstance(value, bool) + for value in (console_enabled, color_enabled, file_enabled, delete_previous) ): raise PlanError("logging enable, color, file, and deletion settings must be boolean") log_maximum_bytes = logging_raw.get("maximum_bytes", 4 * 1024 * 1024) if ( - not isinstance(log_maximum_bytes, int) - or isinstance(log_maximum_bytes, bool) - or not 1024 <= log_maximum_bytes <= 64 * 1024 * 1024 + not isinstance(log_maximum_bytes, int) + or isinstance(log_maximum_bytes, bool) + or not 1024 <= log_maximum_bytes <= 64 * 1024 * 1024 ): raise PlanError("logging maximum_bytes must be an integer from 1024 to 67108864") retention_days = logging_raw.get("retention_days", 30) if ( - not isinstance(retention_days, int) - or isinstance(retention_days, bool) - or not 0 <= retention_days <= 3650 + not isinstance(retention_days, int) + or isinstance(retention_days, bool) + or not 0 <= retention_days <= 3650 ): raise PlanError("logging retention_days must be an integer from 0 to 3650") diff --git a/logicytics/contracts.py b/logicytics/contracts.py index dc626176..a61eb17a 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -3,11 +3,11 @@ from __future__ import annotations import re -from datetime import datetime -from math import isfinite from abc import ABC, abstractmethod from dataclasses import asdict, dataclass, field +from datetime import datetime from enum import StrEnum +from math import isfinite from pathlib import Path from typing import Any, Mapping @@ -523,7 +523,8 @@ def __post_init__(self) -> None: raise ValueError(f"request {name} must be boolean") if self.selection_only and not self.include: raise ValueError("request selection_only requires explicit included collector IDs") - if not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or not 1 <= self.max_workers <= 64: + if not isinstance(self.max_workers, int) or isinstance(self.max_workers, + bool) or not 1 <= self.max_workers <= 64: raise ValueError("request max_workers must be an integer from 1 to 64") if self.performance_check and self.max_workers != 1: raise ValueError("request performance_check requires max_workers=1") diff --git a/logicytics/discovery.py b/logicytics/discovery.py index eddccd87..ef8ba00d 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -352,9 +352,9 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: candidate.static_errors.append(f"collector class must be named {expected_class}") elif not any( (isinstance(base, ast.Name) and base.id == ( - "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) or (isinstance(base, ast.Attribute) and base.attr == ( - "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) for base in public_classes[0].bases ): candidate.static_errors.append("collector class must inherit from its required base class") @@ -420,8 +420,8 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: elif Capability.SUBPROCESS not in metadata.capabilities: candidate.runtime_error = "legacy script mods must declare the subprocess capability" elif ( - candidate.execution_type != "mod_python" - and Capability.FILESYSTEM_WRITE not in metadata.capabilities + candidate.execution_type != "mod_python" + and Capability.FILESYSTEM_WRITE not in metadata.capabilities ): candidate.runtime_error = ( "non-Python mods must declare filesystem_write because their host mutations " @@ -449,10 +449,10 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat metadata_calls = [ node for node in ast.walk(methods["metadata"]) if isinstance(node, ast.Call) - and ( - isinstance(node.func, ast.Name) and node.func.id == "CollectorMetadata" - or isinstance(node.func, ast.Attribute) and node.func.attr == "CollectorMetadata" - ) + and ( + isinstance(node.func, ast.Name) and node.func.id == "CollectorMetadata" + or isinstance(node.func, ast.Attribute) and node.func.attr == "CollectorMetadata" + ) ] if len(metadata_calls) != 1: candidate.static_errors.append( @@ -491,7 +491,7 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat for method in lifecycle_methods: for call in ast.walk(method): if not isinstance(call, ast.Call) or not ( - isinstance(call.func, ast.Attribute) and call.func.attr == "register_file" + isinstance(call.func, ast.Attribute) and call.func.attr == "register_file" ): continue media_keyword = next( @@ -501,7 +501,7 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat if media_keyword is None: registered_media_types.add("application/octet-stream") elif isinstance(media_keyword.value, ast.Constant) and isinstance( - media_keyword.value.value, str + media_keyword.value.value, str ): registered_media_types.add(media_keyword.value.value) else: diff --git a/logicytics/environment.py b/logicytics/environment.py index e22e047f..86fe85bd 100644 --- a/logicytics/environment.py +++ b/logicytics/environment.py @@ -30,8 +30,8 @@ def inspect_environment() -> EnvironmentReport: is_administrator = windows_api_adapter.is_administrator() try: with registry_adapter.OpenKey( - registry_adapter.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System", + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System", ) as key: uac_enabled = bool(registry_adapter.QueryValueEx(key, "EnableLUA")[0]) except OSError: diff --git a/logicytics/interaction.py b/logicytics/interaction.py index bd7955d2..6ea0bc5f 100644 --- a/logicytics/interaction.py +++ b/logicytics/interaction.py @@ -79,7 +79,8 @@ def match_flag( old_input = item.get("input") old_flag = item.get("matched_flag") if isinstance(old_input, str) and isinstance(old_flag, str) and old_flag in FLAG_DESCRIPTIONS: - historical.append((SequenceMatcher(None, _normalized(user_input), _normalized(old_input)).ratio(), old_flag)) + historical.append( + (SequenceMatcher(None, _normalized(user_input), _normalized(old_input)).ratio(), old_flag)) if historical: history_score, history_flag = sorted(historical, key=lambda item: (-item[0], item[1]))[0] if history_score > direct_score: @@ -151,10 +152,10 @@ def write_usage_graph(path: Path, statistics: Mapping[str, object]) -> Path: f'{count}', )) svg = ( - f'' - 'Logicytics flag usage' - + "".join(rows) + "\n" + f'' + 'Logicytics flag usage' + + "".join(rows) + "\n" ) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(svg, encoding="utf-8") diff --git a/logicytics/logging.py b/logicytics/logging.py index 47a50b29..10ab0013 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -44,11 +44,11 @@ class ApplicationLogger(EventLogger): """Thread-safe human-readable file and colored-console event sink.""" def __init__( - self, - path: Path, - settings: LoggingSettings, - *, - console: TextIO | None = None, + self, + path: Path, + settings: LoggingSettings, + *, + console: TextIO | None = None, ) -> None: self.path = path.resolve() self.settings = settings @@ -64,9 +64,9 @@ def _prepare_file(self) -> None: cutoff = time() - self.settings.retention_days * 86400 for candidate in self.path.parent.glob("Logicytics*.log"): if ( - candidate != self.path - and candidate.is_file() - and candidate.stat().st_mtime < cutoff + candidate != self.path + and candidate.is_file() + and candidate.stat().st_mtime < cutoff ): candidate.unlink() self._truncate_file() @@ -138,10 +138,10 @@ def dispatch(self, messages: Iterable[str]) -> None: def get_application_logger( - path: Path, - settings: LoggingSettings, - *, - console: TextIO | None = None, + path: Path, + settings: LoggingSettings, + *, + console: TextIO | None = None, ) -> ApplicationLogger: """Return one configured logger instance per canonical application log path.""" resolved = path.resolve() diff --git a/logicytics/maintenance.py b/logicytics/maintenance.py index 364e1738..3acaf06b 100644 --- a/logicytics/maintenance.py +++ b/logicytics/maintenance.py @@ -89,23 +89,23 @@ def _parse_manifest(payload: object, source: str) -> IntegrityManifest: validated: dict[str, str] = {} for name, digest in files.items(): if ( - not isinstance(name, str) - or not isinstance(digest, str) - or _SHA256.fullmatch(digest) is None + not isinstance(name, str) + or not isinstance(digest, str) + or _SHA256.fullmatch(digest) is None ): raise ValueError("integrity manifest file entries require path and SHA-256 strings") path = PurePosixPath(name) if ( - name == "." - or path.is_absolute() - or ".." in path.parts - or path.as_posix() != name - or "\\" in name + name == "." + or path.is_absolute() + or ".." in path.parts + or path.as_posix() != name + or "\\" in name ): raise ValueError(f"integrity manifest path is unsafe: {name!r}") if any( - part in _EXCLUDED_PARTS or part.startswith(".") and part != ".github" - for part in path.parts + part in _EXCLUDED_PARTS or part.startswith(".") and part != ".github" + for part in path.parts ): raise ValueError(f"integrity manifest path is excluded: {name!r}") validated[name] = digest @@ -134,7 +134,7 @@ def fetch_remote_manifest(settings: MaintenanceSettings) -> IntegrityManifest | def load_local_manifest( - project_root: Path, settings: MaintenanceSettings + project_root: Path, settings: MaintenanceSettings ) -> IntegrityManifest | None: """Load the configured project-owned manifest without leaving the repository.""" path = (project_root / settings.local_manifest_path).resolve() @@ -172,7 +172,7 @@ def project_files(project_root: Path, settings: MaintenanceSettings) -> tuple[Pa def build_manifest( - project_root: Path, settings: MaintenanceSettings, version: str + project_root: Path, settings: MaintenanceSettings, version: str ) -> IntegrityManifest: """Create a deterministic required-file manifest from eligible project files.""" if _VERSION.fullmatch(version) is None: @@ -186,9 +186,9 @@ def build_manifest( def write_local_manifest( - project_root: Path, - settings: MaintenanceSettings, - manifest: IntegrityManifest, + project_root: Path, + settings: MaintenanceSettings, + manifest: IntegrityManifest, ) -> Path: """Atomically publish an explicitly approved local manifest update.""" path = (project_root / settings.local_manifest_path).resolve() @@ -208,8 +208,8 @@ def write_local_manifest( def write_legacy_ini_manifest( - project_root: Path, - manifest: IntegrityManifest, + project_root: Path, + manifest: IntegrityManifest, ) -> Path: """Atomically update only version and file membership in historical CODE/config.ini.""" if _VERSION.fullmatch(manifest.version) is None: @@ -247,9 +247,9 @@ def write_legacy_ini_manifest( def compare_files( - project_root: Path, - settings: MaintenanceSettings, - manifest: IntegrityManifest, + project_root: Path, + settings: MaintenanceSettings, + manifest: IntegrityManifest, ) -> dict[str, list[str]]: """Report exact missing, modified, extra, and unchanged project files.""" root = project_root.resolve() @@ -286,6 +286,7 @@ def local_version(project_root: Path) -> str: def compare_versions(local: str, remote: str) -> str: """Compare semantic versions while treating snapshots as older than stable peers.""" + def parsed(value: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]]: match = _VERSION.fullmatch(value) if match is None: @@ -330,7 +331,7 @@ def python_support(settings: MaintenanceSettings) -> dict[str, str]: def maintenance_diagnostics( - project_root: Path, settings: MaintenanceSettings + project_root: Path, settings: MaintenanceSettings ) -> dict[str, object]: """Resolve optional remote/local integrity evidence without changing collection state.""" remote = fetch_remote_manifest(settings) @@ -380,7 +381,7 @@ def developer_checks(project_root: Path, settings: MaintenanceSettings) -> dict[ public_features = [ node.name for node in tree.body if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) - and not node.name.startswith("_") + and not node.name.startswith("_") ] if len(public_features) > 12: crowded_modules.append(relative.as_posix()) diff --git a/logicytics/manifest.py b/logicytics/manifest.py index a95b89b1..a2958022 100644 --- a/logicytics/manifest.py +++ b/logicytics/manifest.py @@ -2,10 +2,10 @@ from __future__ import annotations +import getpass import json import os import platform -import getpass from dataclasses import asdict, dataclass, field from datetime import datetime, timezone from pathlib import Path, PurePosixPath diff --git a/logicytics/modes.py b/logicytics/modes.py index 7e9d0d73..3460efbe 100644 --- a/logicytics/modes.py +++ b/logicytics/modes.py @@ -85,8 +85,8 @@ class ExecutionMode: def resolve_execution_mode( - selected: str | None, - legacy_flags: Mapping[str, bool], + selected: str | None, + legacy_flags: Mapping[str, bool], ) -> ExecutionMode | None: """Resolve a user-facing name or exactly one parser-exclusive legacy alias.""" aliases = [name for flag, name in LEGACY_MODE_ALIASES.items() if legacy_flags.get(flag, False)] @@ -112,7 +112,7 @@ def _candidate_modes(candidate: CollectorCandidate) -> tuple[str, ...]: for mode in EXECUTION_MODES.values(): if candidate.kind is CollectorKind.MOD: enabled = mode.enable_mods and not ( - mode.non_python_only and candidate.execution_type == "mod_python" + mode.non_python_only and candidate.execution_type == "mod_python" ) elif candidate.kind is CollectorKind.PLUGIN: enabled = False diff --git a/logicytics/output_layout.py b/logicytics/output_layout.py index 221f68ef..bbd22d21 100644 --- a/logicytics/output_layout.py +++ b/logicytics/output_layout.py @@ -39,12 +39,12 @@ def ensure_output_layout(output_root: Path) -> OutputLayout: """Create the complete stable output tree for a state-changing application action.""" layout = output_layout(output_root) for directory in ( - layout.data, - layout.logs, - layout.debug_logs, - layout.performance_logs, - layout.packages, - layout.hashes, + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, ): directory.mkdir(parents=True, exist_ok=True) return layout diff --git a/logicytics/planner.py b/logicytics/planner.py index e2459b7d..2bd2e359 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -2,9 +2,9 @@ from __future__ import annotations -import sys import hashlib import json +import sys from dataclasses import asdict from dataclasses import dataclass from types import MappingProxyType @@ -104,9 +104,9 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: candidate for candidate in report.invalid if candidate.kind is CollectorKind.CORE - or candidate.selection_id in request.include - or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) - or (request.enable_mods and candidate.kind is CollectorKind.MOD) + or candidate.selection_id in request.include + or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) + or (request.enable_mods and candidate.kind is CollectorKind.MOD) ] if invalid_selected: details = "; ".join( diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index cc5d5ae4..97da6f00 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -2,16 +2,16 @@ from __future__ import annotations -import subprocess -import socket as _socket import ctypes -import shutil import os +import shutil import signal +import socket as _socket +import subprocess import tempfile -from pathlib import Path from collections.abc import Sequence from datetime import datetime, timedelta, timezone +from pathlib import Path from typing import Any try: @@ -221,6 +221,7 @@ def is_administrator(self) -> bool | None: def process_working_set(self, process_id: int) -> int | None: """Return a Windows process working set without exposing raw Win32 handles.""" + class ProcessMemoryCounters(ctypes.Structure): _fields_ = [ ("cb", ctypes.c_ulong), @@ -254,6 +255,7 @@ class ProcessMemoryCounters(ctypes.Structure): def process_descendants(self, parent_process_id: int) -> tuple[int, ...]: """Snapshot descendants of one Windows process through Toolhelp APIs.""" + class ProcessEntry(ctypes.Structure): _fields_ = [ ("dwSize", ctypes.c_ulong), diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 51cdb68f..1480f853 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -23,8 +23,8 @@ from uuid import uuid4 from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.configuration import AppConfig from logicytics.command_runner import parse_level_messages +from logicytics.configuration import AppConfig from logicytics.contracts import ( Artifact, Capability, @@ -41,12 +41,12 @@ from logicytics.discovery import CollectorCandidate from logicytics.errors import LogicyticsError from logicytics.logging import FileEventLogger, get_application_logger, get_event_logger -from logicytics.platform_adapters import process_adapter, windows_api_adapter from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now -from logicytics.packaging import package_manifest -from logicytics.output_layout import ensure_output_layout from logicytics.output_contracts import core_output_contract +from logicytics.output_layout import ensure_output_layout +from logicytics.packaging import package_manifest from logicytics.planner import RunPlan +from logicytics.platform_adapters import process_adapter, windows_api_adapter @dataclass(slots=True) @@ -144,7 +144,8 @@ def _check_read(self, value: object) -> None: "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa" } or path.suffix.casefold() in {".pem", ".ppk"} sensitive = private_key or browser_data or any( - label in path.name.casefold() for label in ("cookie", "credential", "password", "token", "secret", "login data") + label in path.name.casefold() for label in + ("cookie", "credential", "password", "token", "secret", "login data") ) if browser_data and Capability.BROWSER_DATA not in self.capabilities: raise PermissionError("collector requires declared browser_data capability") @@ -691,8 +692,8 @@ def run(self, plan: RunPlan) -> RunOutcome: if plan.request.performance_check: self._write_performance_report(run_directory, manifest) should_package = ( - self.configuration.runtime.package_completed_runs - and plan.request.output_policy is OutputPolicy.PACKAGE + self.configuration.runtime.package_completed_runs + and plan.request.output_policy is OutputPolicy.PACKAGE ) if should_package: try: @@ -859,19 +860,19 @@ def _supervise( candidate.metadata.resource_class is ResourceClass.INTERACTIVE or any(worker.resource_class is ResourceClass.INTERACTIVE for worker in active.values()) or ( - candidate.metadata.resource_class is not ResourceClass.GENERAL - and any( - worker.resource_class is candidate.metadata.resource_class - for worker in active.values() - ) + candidate.metadata.resource_class is not ResourceClass.GENERAL + and any( + worker.resource_class is candidate.metadata.resource_class + for worker in active.values() + ) ) ): break reserved_output_bytes = sum(worker.reserved_output_bytes for worker in active.values()) remaining_output_bytes = ( - self.configuration.runtime.maximum_run_output_bytes - - committed_output_bytes - - reserved_output_bytes + self.configuration.runtime.maximum_run_output_bytes + - committed_output_bytes + - reserved_output_bytes ) if remaining_output_bytes < 1: if active: diff --git a/tests/golden/bios_info.html b/tests/golden/bios_info.html index e05c0e3c..ecafef39 100644 --- a/tests/golden/bios_info.html +++ b/tests/golden/bios_info.html @@ -1,17 +1,39 @@ - BIOS information - -

BIOS information

- - - - - - - - - -
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
- + + + BIOS information + +

BIOS information

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
+ diff --git a/tests/golden/manifest.json b/tests/golden/manifest.json index 0e6c13c8..f26349d4 100644 --- a/tests/golden/manifest.json +++ b/tests/golden/manifest.json @@ -35,7 +35,9 @@ "worker_pid": null } ], - "configuration": {"schema_version": 4}, + "configuration": { + "schema_version": 4 + }, "engine_version": "4.0", "errors": [], "finished_at": "", @@ -59,9 +61,13 @@ }, "parent_run_id": null, "plan_fingerprint": "0000000000000000000000000000000000000000000000000000000000000000", - "request": {"profile": "minimal"}, + "request": { + "profile": "minimal" + }, "requested_at": "", - "resolved_plan": ["core.system.system_info"], + "resolved_plan": [ + "core.system.system_info" + ], "run_id": "run-00000000000000000000000000000000", "skipped_collectors": [], "status": "succeeded", diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index 82faf478..67c92dae 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -50,7 +50,7 @@ def test_sensitive_copy_cancellation_removes_unpublished_bytes(self) -> None: source_root.mkdir() (source_root / "password.txt").write_text("sensitive", encoding="utf-8") context = _Context(root / "workspace", {"root": str(source_root), "max_directories": 10, - "max_matches": 10}) + "max_matches": 10}) context.workspace.mkdir() def cancel_after_copy(source: Path, destination: Path) -> Path: @@ -175,7 +175,7 @@ def test_packet_and_memory_loops_stop_before_serialization(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) packet_context = _Context(root / "packet", {"packet_count": 2, "timeout_seconds": 2, - "retry_window_seconds": 0, "interface": "127.0.0.1"}) + "retry_window_seconds": 0, "interface": "127.0.0.1"}) packet_context.workspace.mkdir() class FakeSocket: @@ -202,8 +202,8 @@ def cancel_wait(*_args): self.assertFalse((packet_context.workspace / "packet_capture.csv").exists()) memory_context = _Context(root / "memory", {"max_regions": 10, "output_limit_bytes": 4096, - "disk_safety_margin_bytes": 0, - "dump_directory": "maps"}) + "disk_safety_margin_bytes": 0, + "dump_directory": "maps"}) memory_context.workspace.mkdir() class FakeKernel: diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index d232c170..5103ef56 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -2,8 +2,8 @@ from __future__ import annotations -import hashlib import ctypes +import hashlib import io import json import os @@ -19,7 +19,6 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from logicytics import packaging from logicytics import ( load_configuration, open_artifact, @@ -28,30 +27,10 @@ read_artifact, run_collection, ) +from logicytics import packaging from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.command_runner import parse_level_messages, run_command from logicytics.cli import _launch_action_window, _parser, _request, main -from logicytics.file_listing import list_files -from logicytics.logging import ( - ApplicationLogger, - FileEventLogger, - deprecated, - get_application_logger, - get_event_logger, - raise_logged, - timed, -) -from logicytics.interaction import load_history, match_flag, usage_statistics -from logicytics.manifest import write_manifest -from logicytics.maintenance import ( - build_manifest, - compare_files, - compare_versions, - fetch_remote_manifest, - project_files, - write_local_manifest, -) -from logicytics.sysinternals import ensure_sysinternals +from logicytics.command_runner import parse_level_messages, run_command from logicytics.configuration import ( LoggingSettings, MaintenanceSettings, @@ -65,11 +44,8 @@ CollectorResult, CollectorStatus, EvidenceKind, - EstimatedCost, - NetworkAccess, OutputPolicy, PostRunAction, - PrivilegeLevel, ResourceClass, RunRequest, RunStatus, @@ -78,12 +54,32 @@ from logicytics.discovery import PreflightReport, preflight from logicytics.environment import EnvironmentReport from logicytics.errors import ArtifactError, PlanError, PreflightError -from logicytics.packaging import package_run -from logicytics.output_layout import ensure_output_layout +from logicytics.file_listing import list_files +from logicytics.interaction import load_history, match_flag, usage_statistics +from logicytics.logging import ( + ApplicationLogger, + FileEventLogger, + deprecated, + get_application_logger, + get_event_logger, + raise_logged, + timed, +) +from logicytics.maintenance import ( + build_manifest, + compare_files, + compare_versions, + fetch_remote_manifest, + project_files, + write_local_manifest, +) +from logicytics.manifest import write_manifest from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix +from logicytics.output_layout import ensure_output_layout +from logicytics.packaging import package_run from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.runtime import RunSupervisor - +from logicytics.sysinternals import ensure_sysinternals _COLLECTOR = '''"""Create a harmless test artifact.""" @@ -218,6 +214,7 @@ def _delayed_collector_source( class CoreFunctionalityTests(unittest.TestCase): def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: """The application sink is typed, redacted, reusable, colored, and size bounded.""" + class TerminalBuffer(io.StringIO): def isatty(self) -> bool: return True @@ -241,7 +238,7 @@ def isatty(self) -> bool: ) logger = ApplicationLogger(path, settings, console=console) for level in ( - "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION" + "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION" ): logger.event(level, "typed event", password="hidden", sequence=1) logger.dispatch(("WARNING: parsed warning", "plain batch row")) @@ -291,12 +288,12 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self root = Path(temporary) layout = ensure_output_layout(root / "output" / "data") for directory in ( - layout.data, - layout.logs, - layout.debug_logs, - layout.performance_logs, - layout.packages, - layout.hashes, + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, ): self.assertTrue(directory.is_dir()) config_path = root / "logicytics.json" @@ -321,11 +318,11 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self self.assertEqual(7, configuration.logging.retention_days) for invalid_logging in ( - {"level": "TRACE"}, - {"maximum_bytes": True}, - {"retention_days": -1}, - {"console_enabled": 1}, - {"unknown": True}, + {"level": "TRACE"}, + {"maximum_bytes": True}, + {"retention_days": -1}, + {"console_enabled": 1}, + {"unknown": True}, ): config_path.write_text( json.dumps({"schema_version": 4, "logging": invalid_logging}), @@ -335,7 +332,7 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self load_config(root, config_path) def test_maintenance_configuration_requires_pinned_https_and_python_order( - self, + self, ) -> None: """Integrity endpoints and Python policy fail closed during configuration loading.""" with tempfile.TemporaryDirectory() as temporary: @@ -359,10 +356,10 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( self.assertEqual("3.12", loaded.maintenance.recommended_python) for mutation in ( - {"remote_manifest_url": "http://example.invalid/project.json"}, - {"remote_manifest_sha256": None}, - {"local_manifest_path": "../escape.json"}, - {"minimum_python": "3.12", "recommended_python": "3.11"}, + {"remote_manifest_url": "http://example.invalid/project.json"}, + {"remote_manifest_sha256": None}, + {"local_manifest_path": "../escape.json"}, + {"minimum_python": "3.12", "recommended_python": "3.11"}, ): invalid = json.loads(json.dumps(valid)) invalid["maintenance"].update(mutation) @@ -371,7 +368,7 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( load_config(root, config) def test_authenticated_remote_manifest_is_strict_data_only_configuration( - self, + self, ) -> None: """Pinned HTTPS bytes are accepted, while unpinned or execution-bearing data is rejected.""" payload = json.dumps( @@ -415,8 +412,8 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( remote_manifest_sha256=hashlib.sha256(execution_payload).hexdigest(), ) with patch( - "logicytics.maintenance.urllib.request.urlopen", - return_value=execution_response, + "logicytics.maintenance.urllib.request.urlopen", + return_value=execution_response, ): with self.assertRaisesRegex(ValueError, "only schema_version"): fetch_remote_manifest(execution_settings) @@ -462,7 +459,7 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No encoding="utf-8", ) with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", new_callable=io.StringIO + "sys.stdout", new_callable=io.StringIO ): self.assertEqual(0, main(["dev", "--write-manifest", "--next-version", "4.1.0"])) manifest_path = root / "project.manifest.json" @@ -471,7 +468,7 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No self.assertEqual("4.1.0", manifest_payload["version"]) with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", new_callable=io.StringIO + "sys.stdout", new_callable=io.StringIO ): self.assertEqual(0, main(["debug"])) debug_path = root / "output" / "logs" / "debug" / "debug.json" @@ -508,7 +505,7 @@ def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> No ) output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + "sys.stdout", output ): self.assertEqual( 0, @@ -729,6 +726,7 @@ def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files(s [record.status for record in outcome.manifest.collectors], [record.errors for record in outcome.manifest.collectors], ) + def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(self) -> None: """Natural-language actions use configured matching and persist only with consent.""" with tempfile.TemporaryDirectory() as temporary: @@ -967,7 +965,8 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> self.assertEqual(RunStatus.SUCCEEDED, snapshot.status, outcome.manifest.package) self.assertEqual(outcome.run_directory, snapshot.run_directory) self.assertEqual(outcome.manifest_path, snapshot.manifest_path) - self.assertEqual(1, json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"]) + self.assertEqual(1, + json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"]) self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) collector = snapshot.collectors[0] @@ -1021,7 +1020,7 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat collector_path.write_text(_COLLECTOR, encoding="utf-8") output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + "sys.stdout", output ), patch("builtins.input", return_value="") as final_prompt: exit_code = main( ["run", "--default", "--interactive", "--acknowledge-authorization"] @@ -1529,12 +1528,15 @@ def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) - def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, object]]] = [] + class Logger: def event(self, level: str, message: str, **fields: object) -> None: events.append((level, message, fields)) + @deprecated(Logger(), removal_version="5.0", reason="replacement exists") def old() -> str: return "still works" + self.assertEqual("still works", old()) self.assertEqual("function_deprecated", events[0][1]) self.assertEqual("5.0", events[0][2]["removal_version"]) @@ -1542,9 +1544,11 @@ def old() -> str: def test_exception_helper_logs_before_raising(self) -> None: """Exception helpers must preserve the requested exception type and context.""" events: list[tuple[str, str, dict[str, object]]] = [] + class Logger: def event(self, level: str, message: str, **fields: object) -> None: events.append((level, message, fields)) + with self.assertRaises(ValueError): raise_logged(Logger(), ValueError, "invalid setting", setting="workers") self.assertEqual("exception", events[0][0]) @@ -1553,12 +1557,15 @@ def event(self, level: str, message: str, **fields: object) -> None: def test_timed_decorator_records_function_lifecycle(self) -> None: """Timing instrumentation must report start and finish through EventLogger.""" events: list[tuple[str, str, dict[str, object]]] = [] + class Logger: def event(self, level: str, message: str, **fields: object) -> None: events.append((level, message, fields)) + @timed(Logger()) def add(left: int, right: int) -> int: return left + right + self.assertEqual(3, add(1, 2)) self.assertEqual(["function_started", "function_finished"], [event[1] for event in events]) @@ -1858,8 +1865,10 @@ def test_configuration_rejects_unknown_duplicate_and_non_finite_values(self) -> ('{"schema_version":4,"runtime":{"maximum_workers":NaN}}', "non-finite"), ('{"schema_version":4,"collectors":{"plugin.custom":{"value":1e999}}}', "non-finite"), ('{"schema_version":4,"collectors":{"../escape":{}}}', "invalid collector ID"), - ('{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', "invalid setting name"), - ('{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', "unsupported settings"), + ('{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', + "invalid setting name"), + ('{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', + "unsupported settings"), ) for payload, message in invalid: with self.subTest(payload=payload): @@ -2075,7 +2084,7 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s ) output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + "sys.stdout", output ): self.assertEqual( 0, @@ -2173,7 +2182,7 @@ def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: root = Path(temporary) output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + "sys.stdout", output ): self.assertEqual(0, main(["--modes"])) payload = json.loads(output.getvalue()) @@ -2204,7 +2213,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self git = subprocess.CompletedProcess(["git", "--version"], 0, "git version 2.0\n", "") output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( - "logicytics.cli.process_adapter.run", return_value=git + "logicytics.cli.process_adapter.run", return_value=git ), patch("logicytics.cli._launch_action_window", return_value=321) as launch, patch( "sys.stdout", output ): @@ -2218,7 +2227,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self launch.assert_called_once_with(root, "debug") with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", new_callable=io.StringIO + "sys.stdout", new_callable=io.StringIO ) as invalid_output: self.assertEqual(2, main(["update", "--new-window"])) self.assertIn("must be provided together", invalid_output.getvalue()) @@ -2228,7 +2237,7 @@ def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> N root = Path("C:/repo").resolve() process = MagicMock(pid=42) with patch("logicytics.cli.sys.platform", "win32"), patch( - "logicytics.cli.process_adapter.popen", return_value=process + "logicytics.cli.process_adapter.popen", return_value=process ) as popen: self.assertEqual(42, _launch_action_window(root, "preflight")) popen.assert_called_once_with( @@ -2239,7 +2248,7 @@ def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> N close_fds=True, ) with patch("logicytics.cli.sys.platform", "linux"), self.assertRaisesRegex( - OSError, "only on Windows" + OSError, "only on Windows" ): _launch_action_window(root, "debug") @@ -3176,7 +3185,8 @@ def test_collector_resource_progress_persists_in_manifest_summary_and_performanc collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text( - _COLLECTOR.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n").replace( + _COLLECTOR.replace("from pathlib import Path\n", + "from pathlib import Path\nfrom time import sleep\n").replace( ' output = context.workspace / "system.txt"', ' context.report_progress("scan_started", scanned_files=3)\n' ' sleep(0.2)\n' @@ -3803,7 +3813,8 @@ def test_planner_rejects_excluded_missing_and_sensitive_dependency_conflicts(sel report = preflight(root) self.assertEqual((), report.invalid) with self.assertRaisesRegex(PlanError, "sensitive dependency"): - build_plan(report, RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,))) + build_plan(report, + RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,))) approved = build_plan( report, RunRequest( @@ -4696,7 +4707,8 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text( - _COLLECTOR.replace("def collect(self, context: CollectorContext) -> CollectorResult:", "def collect(self, context: CollectorContext) -> ValidationResult:"), + _COLLECTOR.replace("def collect(self, context: CollectorContext) -> CollectorResult:", + "def collect(self, context: CollectorContext) -> ValidationResult:"), encoding="utf-8", ) report = preflight(root) @@ -4912,12 +4924,14 @@ def test_preflight_rejects_collector_print_calls(self) -> None: collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text( - _COLLECTOR.replace("return CollectorResult.succeeded(\"test artifact created\", (artifact,))", "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))"), + _COLLECTOR.replace("return CollectorResult.succeeded(\"test artifact created\", (artifact,))", + "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))"), encoding="utf-8", ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("collectors must not print; use structured progress or logging", report.invalid[0].static_errors) + self.assertIn("collectors must not print; use structured progress or logging", + report.invalid[0].static_errors) def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> None: """Collection-like work must not run before a worker has isolated the collector.""" diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index cc68aed0..f7820cfc 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -80,7 +80,7 @@ def test_process_adapter_starts_shell_free_processes_and_reads_host_memory(self) ) self.assertEqual(("tool", "argument"), popen.call_args.args[0]) with patch("logicytics.platform_adapters.os.name", "nt"), patch.object( - windows_api_adapter, "process_working_set", return_value=4096 + windows_api_adapter, "process_working_set", return_value=4096 ) as memory: self.assertEqual(4096, ProcessAdapter().memory_bytes(42)) memory.assert_called_once_with(42) @@ -124,9 +124,9 @@ def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: def test_process_adapter_rejects_shell_empty_and_unbounded_timeout_inputs(self) -> None: adapter = ProcessAdapter() for command, options, message in ( - ([], {}, "at least one"), - (["tool"], {"shell": True}, "shell"), - (["tool"], {"timeout": 0}, "timeout"), + ([], {}, "at least one"), + (["tool"], {"shell": True}, "shell"), + (["tool"], {"timeout": 0}, "timeout"), ): with self.subTest(command=command, options=options): with self.assertRaisesRegex(ValueError, message): diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py index 37514d60..34945ee6 100644 --- a/tests/test_release_documentation.py +++ b/tests/test_release_documentation.py @@ -27,10 +27,10 @@ def test_release_identity_is_final_and_no_provisional_claim_remains(self) -> Non self.assertIn("complete recreation", self.release.casefold()) self.assertIn("complete recreation", self.readme.casefold()) for provisional in ( - "being rebuilt", - "early v4 implementation", - "broader collection catalog is being rebuilt", - "to be released", + "being rebuilt", + "early v4 implementation", + "broader collection catalog is being rebuilt", + "to be released", ): with self.subTest(provisional=provisional): self.assertNotIn(provisional, self.readme.casefold()) @@ -104,18 +104,18 @@ def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> Non with self.subTest(document=document): self.assertIn(document, self.readme) for option in ( - "--config", "--match", "--modes", "--profile", "--include", "--exclude", - "--plugins", "--mods", "--workers", "--allow-capability", "--rerun-from", - "--sequential", "--parallel", "--no-package", "--reboot", "--shutdown", - "--acknowledge-authorization", "--interactive", "--apply", + "--config", "--match", "--modes", "--profile", "--include", "--exclude", + "--plugins", "--mods", "--workers", "--allow-capability", "--rerun-from", + "--sequential", "--parallel", "--no-package", "--reboot", "--shutdown", + "--acknowledge-authorization", "--interactive", "--apply", ): with self.subTest(option=option): self.assertIn(option, self.readme) contributor = (self.root / "CONTRIBUTING.md").read_text(encoding="utf-8") for requirement in ( - "Development setup", "Architecture boundaries", "Core collector changes", - "Plugins and MODs", "Configuration changes", "Testing expectations", - "conventional commit", "Developer Certificate of Origin", + "Development setup", "Architecture boundaries", "Core collector changes", + "Plugins and MODs", "Configuration changes", "Testing expectations", + "conventional commit", "Developer Certificate of Origin", ): with self.subTest(requirement=requirement): self.assertIn(requirement, contributor) diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index 613107a0..57aa4411 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -60,9 +60,9 @@ def test_access_denied_is_skipped_not_failed(self) -> None: cancellation_file=workspace / ".cancelled", ) with patch.object( - module.subprocess, - "run", - return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), + module.subprocess, + "run", + return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), ): result = collector_type().collect(context) self.assertEqual(CollectorStatus.SKIPPED, result.status) diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 739f790b..7fc2a71c 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -9,19 +9,19 @@ import unittest from contextlib import ExitStack from pathlib import Path +from unittest.mock import patch from logicytics import ResourceClass +from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.contracts import ( ArtifactWriter, CollectorContext, CollectorStatus, EventLogger, EvidenceKind, ) from logicytics.discovery import preflight from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.output_contracts import core_output_contract -from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.platform_adapters import ( filesystem_adapter, network_adapter, process_adapter, registry_adapter, windows_api_adapter, ) -from unittest.mock import patch class _RejectingWriter(ArtifactWriter): @@ -272,15 +272,15 @@ def test_every_registered_artifact_media_type_is_declared(self) -> None: register_calls = [ node for node in ast.walk(tree) if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "register_file" + and isinstance(node.func, ast.Attribute) + and node.func.attr == "register_file" ] declared_at_calls = { keyword.value.value for node in register_calls for keyword in node.keywords if keyword.arg == "media_type" - and isinstance(keyword.value, ast.Constant) - and isinstance(keyword.value.value, str) + and isinstance(keyword.value, ast.Constant) + and isinstance(keyword.value.value, str) } if any(not any(keyword.arg == "media_type" for keyword in node.keywords) for node in register_calls): @@ -319,7 +319,7 @@ def test_each_core_module_owns_exactly_one_policy_contract(self) -> None: collector_classes = [ node for node in tree.body if isinstance(node, ast.ClassDef) - and any(ast.unparse(base).endswith("CoreCollector") for base in node.bases) + and any(ast.unparse(base).endswith("CoreCollector") for base in node.bases) ] with self.subTest(collector=candidate.metadata.id): self.assertEqual([candidate.expected_class], [node.name for node in collector_classes]) diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py index ee021ed8..925df128 100644 --- a/tests/test_windows_integration.py +++ b/tests/test_windows_integration.py @@ -23,8 +23,8 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: self.assertIn(privilege, {True, False, None}) with registry_adapter.OpenKey( - registry_adapter.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", ) as key: product_name, _ = registry_adapter.QueryValueEx(key, "ProductName") self.assertTrue(str(product_name).strip()) From 0178614081b83e30a51c9dcd56af64df153b1209 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:20:01 +0300 Subject: [PATCH 216/316] fix: PEP warns about protected member of a class or module --- core/packet/connection_graph.py | 4 +- core/system/bios_info.py | 4 +- logicytics/cli.py | 12 +++--- tests/test_core_functionality.py | 72 ++++++++++++++++---------------- tests/test_golden_outputs.py | 8 ++-- 5 files changed, 50 insertions(+), 50 deletions(-) diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 94c36c8a..879a761f 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -14,7 +14,7 @@ def _is_access_denied(detail: str) -> bool: return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) -def _render_connection_graph(command_output: str) -> str: +def render_connection_graph(command_output: str) -> str: """Normalize netstat rows into deterministic, sorted Graphviz DOT source.""" edges: set[tuple[str, str, str]] = set() for line in command_output.splitlines(): @@ -74,7 +74,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.SKIPPED, "connection graph access was denied for the current account", errors=(detail,)) return CollectorResult(CollectorStatus.FAILED, "connection graph query failed", errors=(detail,)) - rendered = _render_connection_graph(completed.stdout) + rendered = render_connection_graph(completed.stdout) output = context.workspace / "connection_graph.dot" output.write_text(rendered, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/vnd.graphviz") diff --git a/core/system/bios_info.py b/core/system/bios_info.py index 3eef110a..efdd1cc2 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -24,7 +24,7 @@ def _is_access_denied(detail: str) -> bool: return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) -def _render_bios_table(bios: dict[str, object]) -> str: +def render_bios_table(bios: dict[str, object]) -> str: """Render trusted structured BIOS fields into a portable evidence table.""" rows = "\n".join( f" {html.escape(label)}{html.escape(str(bios.get(key) or 'unavailable'))}" @@ -117,7 +117,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not isinstance(bios, dict): return CollectorResult(CollectorStatus.FAILED, "BIOS CIM query returned an unexpected result") output = context.workspace / "bios_info.html" - output.write_text(_render_bios_table(bios), encoding="utf-8") + output.write_text(render_bios_table(bios), encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/html") context.report_progress("bios_info_finished", bytes_written=artifact.size_bytes) return CollectorResult.succeeded("BIOS information collected", (artifact,)) diff --git a/logicytics/cli.py b/logicytics/cli.py index d22fb9b4..7e3530a5 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -47,7 +47,7 @@ def _project_root() -> Path: return Path(__file__).resolve().parent.parent -def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: +def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: legacy_flags = { flag: getattr(arguments, flag, False) for flag in LEGACY_MODE_ALIASES @@ -154,7 +154,7 @@ def _request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: ) -def _parser() -> argparse.ArgumentParser: +def parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument( "--config", @@ -337,7 +337,7 @@ def _write_json(path: Path, payload: object) -> Path: return path -def _launch_action_window(root: Path, action: str) -> int: +def launch_action_window(root: Path, action: str) -> int: """Launch one allowlisted maintenance action in a separate visible Windows console.""" if sys.platform != "win32": raise OSError("new command windows are supported only on Windows") @@ -420,7 +420,7 @@ def _run_developer_action( def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" - parser = _parser() + parser = parser() arguments = parser.parse_args(argv) standalone_actions = sum(bool(value) for value in (arguments.usage, arguments.match, arguments.modes)) if standalone_actions > 1: @@ -531,12 +531,12 @@ def main(argv: list[str] | None = None) -> int: update_succeeded = not arguments.apply or payload.get("returncode") == 0 if arguments.new_window and update_succeeded: payload["launched_action"] = arguments.launch_action - payload["launched_process_id"] = _launch_action_window(root, arguments.launch_action) + payload["launched_process_id"] = launch_action_window(root, arguments.launch_action) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if update_succeeded else 1 if arguments.command == "dev": return _run_developer_action(root, configuration, arguments) - plan = build_plan(report, _request(arguments, configuration.runtime.default_max_workers)) + plan = build_plan(report, request(arguments, configuration.runtime.default_max_workers)) if arguments.command == "plan": print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) return 0 diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 5103ef56..8c08a58a 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -29,7 +29,7 @@ ) from logicytics import packaging from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.cli import _launch_action_window, _parser, _request, main +from logicytics.cli import launch_action_window, parser, request, main from logicytics.command_runner import parse_level_messages, run_command from logicytics.configuration import ( LoggingSettings, @@ -676,9 +676,9 @@ def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers(self) - )) self.assertEqual(["mod.batch_mod", "mod.python_mod"], [item.metadata.id for item in modded.collectors]) self.assertEqual(["mod.batch_mod"], [item.metadata.id for item in nopy.collectors]) - parser = _parser() - self.assertTrue(_request(parser.parse_args(["run", "--modded"]), 2).enable_mods) - nopy_request = _request(parser.parse_args(["run", "--nopy"]), 2) + parser = parser() + self.assertTrue(request(parser.parse_args(["run", "--modded"]), 2).enable_mods) + nopy_request = request(parser.parse_args(["run", "--nopy"]), 2) self.assertTrue(nopy_request.enable_mods) self.assertTrue(nopy_request.non_python_only) @@ -827,17 +827,17 @@ def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(se def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging(self) -> None: """Power actions remain explicit and cannot run before durable package publication.""" - arguments = _parser().parse_args([ + arguments = parser().parse_args([ "run", "--shutdown", "--performance-check", "--acknowledge-authorization", ]) - request = _request(arguments, default_workers=4) + request = request(arguments, default_workers=4) self.assertEqual(PostRunAction.SHUTDOWN, request.post_run_action) self.assertEqual(OutputPolicy.PACKAGE, request.output_policy) self.assertEqual(1, request.max_workers) with self.assertRaises(SystemExit): - _parser().parse_args(["run", "--reboot", "--shutdown"]) + parser().parse_args(["run", "--reboot", "--shutdown"]) with self.assertRaisesRegex(ValueError, "require packaged output"): - _request(_parser().parse_args(["run", "--reboot", "--no-package"]), default_workers=1) + request(parser().parse_args(["run", "--reboot", "--no-package"]), default_workers=1) with tempfile.TemporaryDirectory() as temporary: logger = FileEventLogger(Path(temporary) / "events.jsonl", run_id="run-" + "a" * 32) @@ -1415,7 +1415,7 @@ def test_builtin_profiles_select_declared_members_and_reject_unknown_names(self) build_plan(report, RunRequest(profile="invented")) with patch("sys.stderr", new_callable=io.StringIO) as errors: with self.assertRaises(SystemExit): - _parser().parse_args(["plan", "--profile", "invented"]) + parser().parse_args(["plan", "--profile", "invented"]) self.assertIn("invalid choice", errors.getvalue()) def test_offline_profile_rejects_network_collectors_even_with_explicit_approval(self) -> None: @@ -2061,9 +2061,9 @@ def test_configuration_validates_bounded_network_and_packet_settings(self) -> No def test_run_parser_accepts_performance_check(self) -> None: """The run command must expose the performance mode used by the request builder.""" - arguments = _parser().parse_args(["run", "--performance-check"]) + arguments = parser().parse_args(["run", "--performance-check"]) self.assertTrue(arguments.performance_check) - request = _request(arguments, default_workers=4) + request = request(arguments, default_workers=4) self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) @@ -2129,7 +2129,7 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" - parser = _parser() + parser = parser() expected = { "standard": ("standard", 1, False, False, False), "balanced": ("standard", 4, False, False, False), @@ -2144,7 +2144,7 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None self.assertEqual(set(expected), {item["name"] for item in mode_matrix()["modes"]}) for name, contract in expected.items(): with self.subTest(mode=name): - request = _request(parser.parse_args(["run", "--mode", name]), 4) + request = request(parser.parse_args(["run", "--mode", name]), 4) self.assertEqual( contract, ( @@ -2167,12 +2167,12 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None } for field, mode_name in LEGACY_MODE_ALIASES.items(): with self.subTest(alias=alias_flags[field]): - legacy = _request(parser.parse_args(["run", alias_flags[field]]), 4) - named = _request(parser.parse_args(["run", "--mode", mode_name]), 4) + legacy = request(parser.parse_args(["run", alias_flags[field]]), 4) + named = request(parser.parse_args(["run", "--mode", mode_name]), 4) self.assertEqual(named, legacy) with self.assertRaisesRegex(ValueError, "--profile"): - _request(parser.parse_args(["run", "--mode", "quick", "--profile", "deep"]), 4) + request(parser.parse_args(["run", "--mode", "quick", "--profile", "deep"]), 4) with patch("sys.stderr"), self.assertRaises(SystemExit): parser.parse_args(["run", "--mode", "quick", "--minimal"]) @@ -2202,7 +2202,7 @@ def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> self.assertIn("run", rendered) with patch("sys.stderr", new_callable=io.StringIO) as errors: with self.assertRaises(SystemExit): - _parser().parse_args(["run", "--default", "--performance-check"]) + parser().parse_args(["run", "--default", "--performance-check"]) self.assertIn("not allowed with argument", errors.getvalue()) def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self) -> None: @@ -2214,7 +2214,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self output = io.StringIO() with patch("logicytics.cli._project_root", return_value=root), patch( "logicytics.cli.process_adapter.run", return_value=git - ), patch("logicytics.cli._launch_action_window", return_value=321) as launch, patch( + ), patch("logicytics.cli.launch_action_window", return_value=321) as launch, patch( "sys.stdout", output ): self.assertEqual( @@ -2239,7 +2239,7 @@ def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> N with patch("logicytics.cli.sys.platform", "win32"), patch( "logicytics.cli.process_adapter.popen", return_value=process ) as popen: - self.assertEqual(42, _launch_action_window(root, "preflight")) + self.assertEqual(42, launch_action_window(root, "preflight")) popen.assert_called_once_with( [sys.executable, "-m", "logicytics", "preflight"], cwd=root, @@ -2250,14 +2250,14 @@ def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> N with patch("logicytics.cli.sys.platform", "linux"), self.assertRaisesRegex( OSError, "only on Windows" ): - _launch_action_window(root, "debug") + launch_action_window(root, "debug") def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: """Execution policy is selectable directly instead of relying on compatibility modes.""" - parser = _parser() - sequential = _request(parser.parse_args(["run", "--sequential"]), default_workers=4) - parallel = _request(parser.parse_args(["run", "--parallel"]), default_workers=4) - bounded_parallel = _request( + parser = parser() + sequential = request(parser.parse_args(["run", "--sequential"]), default_workers=4) + parallel = request(parser.parse_args(["run", "--parallel"]), default_workers=4) + bounded_parallel = request( parser.parse_args(["run", "--parallel", "--workers", "3"]), default_workers=4, ) @@ -2270,7 +2270,7 @@ def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: """Contradictory worker policies fail before creating a collection plan.""" - parser = _parser() + parser = parser() conflicts = ( (["run", "--sequential", "--workers", "2"], 4, "sequential execution"), (["run", "--sequential", "--threaded"], 4, "legacy --threaded"), @@ -2282,7 +2282,7 @@ def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: for arguments, default_workers, error in conflicts: with self.subTest(arguments=arguments, default_workers=default_workers): with self.assertRaisesRegex(ValueError, error): - _request(parser.parse_args(arguments), default_workers=default_workers) + request(parser.parse_args(arguments), default_workers=default_workers) with patch("sys.stderr"), self.assertRaises(SystemExit): parser.parse_args(["run", "--sequential", "--parallel"]) @@ -2299,12 +2299,12 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( "resolved_plan": [collector_id], } manifest_path.write_text(json.dumps(valid_manifest), encoding="utf-8") - parser = _parser() + parser = parser() with self.assertRaisesRegex(ValueError, "explicit --include"): - _request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) + request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) with self.assertRaisesRegex(ValueError, "not present in the original"): - _request( + request( parser.parse_args( ["run", "--rerun-from", str(manifest_path), "--include", "core.system.other"] ), @@ -2312,7 +2312,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( ) manifest_path.write_text(json.dumps({**valid_manifest, "status": "running"}), encoding="utf-8") with self.assertRaisesRegex(ValueError, "finalized"): - _request( + request( parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), 2, ) @@ -2321,7 +2321,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( invalid_manifest = {**valid_manifest, "manifest_schema_version": schema_version} manifest_path.write_text(json.dumps(invalid_manifest), encoding="utf-8") with self.assertRaisesRegex(ValueError, "unsupported schema_version"): - _request( + request( parser.parse_args( ["run", "--rerun-from", str(manifest_path), "--include", collector_id] ), @@ -2329,7 +2329,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( ) manifest_path.write_text("not json", encoding="utf-8") with self.assertRaisesRegex(ValueError, "cannot be loaded"): - _request( + request( parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), 2, ) @@ -3006,7 +3006,7 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( original_package_path = Path(original.manifest.package["path"]) original_package = original_package_path.read_bytes() selected_id = "core.system.z_selected" - arguments = _parser().parse_args( + arguments = parser().parse_args( [ "run", "--rerun-from", @@ -3017,7 +3017,7 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( "--sequential", ] ) - request = _request(arguments, default_workers=4) + request = request(arguments, default_workers=4) self.assertEqual(original.manifest.run_id, request.rerun_from) rerun = RunSupervisor(root, configuration).run(build_plan(report, request)) @@ -3575,10 +3575,10 @@ def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: _delayed_collector_source(filename, 0.3), encoding="utf-8", ) - arguments = _parser().parse_args( + arguments = parser().parse_args( ["run", execution_mode, "--acknowledge-authorization"] ) - request = _request(arguments, default_workers=2) + request = request(arguments, default_workers=2) report = preflight(root) self.assertEqual((), report.invalid) outcome = RunSupervisor(root, default_config(root)).run(build_plan(report, request)) diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index bdb2e24b..da80e4d5 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -9,10 +9,10 @@ from subprocess import CompletedProcess from unittest.mock import patch -from core.packet.connection_graph import _render_connection_graph +from core.packet.connection_graph import render_connection_graph from core.process import running_processes from core.system import system_details -from core.system.bios_info import _render_bios_table +from core.system.bios_info import render_bios_table from logicytics.contracts import ( Artifact, ArtifactWriter, CollectorContext, EvidenceKind, EventLogger, RunStatus, ) @@ -85,7 +85,7 @@ def test_csv_output_matches_golden_file(self) -> None: self.assertEqual(expected.encode(), writer.contents) def test_html_output_matches_golden_file(self) -> None: - rendered = _render_bios_table({ + rendered = render_bios_table({ "Manufacturer": "Example & Sons", "Name": "Golden ", "SMBIOSBIOSVersion": "1.2.3", @@ -95,7 +95,7 @@ def test_html_output_matches_golden_file(self) -> None: self.assertEqual((_GOLDEN / "bios_info.html").read_text(encoding="utf-8"), rendered) def test_graph_output_matches_golden_file(self) -> None: - rendered = _render_connection_graph( + rendered = render_connection_graph( "UDP [::1]:5353 [::1]:5354 999\n" "TCP 10.0.0.5:50000 1.1.1.1:443 ESTABLISHED 4242\n" "TCP 10.0.0.5:50000 1.1.1.1:443 ESTABLISHED 4242\n" From 5d569172f8c5e3c1869d8d085170bc46e144031d Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:25:29 +0300 Subject: [PATCH 217/316] fix: PEP warns about `None` object is not callable due to weak types --- logicytics/api.py | 40 +++++++++++---- logicytics/platform_adapters.py | 18 +++++-- logicytics/validation_worker.py | 86 +++++++++++++++++++++++++++------ 3 files changed, 116 insertions(+), 28 deletions(-) diff --git a/logicytics/api.py b/logicytics/api.py index 6c912215..be72d189 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -6,6 +6,7 @@ import json import os import re +import sys from dataclasses import dataclass from datetime import datetime from math import isfinite @@ -386,38 +387,59 @@ def open_artifact( ) -> Path: """Verify and open one registered artifact with the platform's associated application.""" if not isinstance(artifact_id, str) or _ARTIFACT_ID.fullmatch(artifact_id) is None: - raise ArtifactError("artifact_id must be a canonical registered artifact identifier") + raise ArtifactError( + "artifact_id must be a canonical registered artifact identifier" + ) + snapshot = query_run( project_root, run_id, configuration=configuration, config_path=config_path, ) - artifact = next((item for item in snapshot.artifacts if item.id == artifact_id), None) + + artifact = next( + (item for item in snapshot.artifacts if item.id == artifact_id), + None, + ) if artifact is None: - raise ArtifactError("artifact is not registered in the selected run manifest") + raise ArtifactError( + "artifact is not registered in the selected run manifest" + ) + root = snapshot.run_directory / "artifacts" source = root.joinpath(*PurePosixPath(artifact.relative_path).parts) owner = root / artifact.collector_id.replace(".", "_") + try: if root.resolve(strict=True) != root or owner.resolve(strict=True) != owner: raise ValueError("artifact ownership roots must not be redirected") + resolved = source.resolve(strict=True) resolved.relative_to(owner) + if not source.is_file() or source.stat().st_size != artifact.size_bytes: - raise ArtifactError("registered artifact size does not match its manifest") + raise ArtifactError( + "registered artifact size does not match its manifest" + ) + digest = hashlib.sha256() with source.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): digest.update(block) + except (OSError, ValueError) as error: raise ArtifactError( "registered artifact escapes its collector-owned store or cannot be opened" ) from error + if digest.hexdigest() != artifact.sha256: - raise ArtifactError("registered artifact failed manifest SHA-256 verification") - platform_opener = getattr(os, "startfile", None) - if platform_opener is None: - raise OSError("opening artifacts requires a Windows associated-file handler") - platform_opener(source) + raise ArtifactError( + "registered artifact failed manifest SHA-256 verification" + ) + + if sys.platform != "win32": + raise OSError("opening artifacts requires Windows") + + os.startfile(source) return source diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 97da6f00..534bd0c6 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -8,6 +8,7 @@ import signal import socket as _socket import subprocess +import sys import tempfile from collections.abc import Sequence from datetime import datetime, timedelta, timezone @@ -206,12 +207,19 @@ class WindowsApiAdapter: @staticmethod def load_library(name: str): - if not isinstance(name, str) or not name or any(character in name for character in "/\\\x00"): - raise ValueError("Win32 library name must be a simple non-empty name") - loader = getattr(ctypes, "WinDLL", None) - if loader is None: + if ( + not isinstance(name, str) + or not name + or any(character in name for character in "/\\\x00") + ): + raise ValueError( + "Win32 library name must be a simple non-empty name" + ) + + if sys.platform != "win32": raise OSError("Win32 libraries are unavailable on this platform") - return loader(name, use_last_error=True) + + return ctypes.WinDLL(name, use_last_error=True) def is_administrator(self) -> bool | None: try: diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index 202256bd..2644066e 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -8,7 +8,9 @@ import os import sys import tempfile +from collections.abc import Callable from pathlib import Path +from typing import cast from logicytics.contracts import ( Collector, @@ -106,10 +108,17 @@ def _load_module(path: Path): return module -def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> CollectorMetadata: +def _validate_contract( + collector_type: type[Collector], + kind: CollectorKind, +) -> CollectorMetadata: required_base = CoreCollector if kind is CollectorKind.CORE else PluginCollector + if not issubclass(collector_type, required_base): - raise ValueError(f"collector must inherit from {required_base.__name__}") + raise ValueError( + f"collector must inherit from {required_base.__name__}" + ) + for method_name, expected_parameters in { "metadata": 0, "validate": 1, @@ -119,31 +128,73 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> "cleanup": 1, }.items(): method = getattr(collector_type, method_name, None) - if method is None: + + if method is None or not callable(method): raise ValueError(f"missing required method: {method_name}") + parameters = list(inspect.signature(method).parameters.values()) + if method_name == "metadata": - parameters = parameters[1:] if parameters and parameters[0].name == "cls" else parameters + parameters = ( + parameters[1:] + if parameters and parameters[0].name == "cls" + else parameters + ) elif parameters and parameters[0].name == "self": parameters = parameters[1:] + if len(parameters) != expected_parameters: - raise ValueError(f"{method_name} has an invalid signature") - dependencies = getattr(collector_type, "dependencies", None) - if dependencies is None or list(inspect.signature(dependencies).parameters.values()): + raise ValueError( + f"{method_name} has an invalid signature" + ) + + raw_dependencies = getattr(collector_type, "dependencies", None) + + if raw_dependencies is None or not callable(raw_dependencies): + raise ValueError("dependencies has an invalid signature") + + dependencies = cast( + Callable[[], tuple[str, ...]], + raw_dependencies, + ) + + if inspect.signature(dependencies).parameters: raise ValueError("dependencies has an invalid signature") + declared_dependencies = dependencies() - if not isinstance(declared_dependencies, tuple) or not all(isinstance(item, str) for item in declared_dependencies): - raise ValueError("dependencies() must return tuple[str, ...]") + + if ( + not isinstance(declared_dependencies, tuple) + or not all( + isinstance(item, str) + for item in declared_dependencies + ) + ): + raise ValueError( + "dependencies() must return tuple[str, ...]" + ) + metadata = collector_type.metadata() + if not isinstance(metadata, CollectorMetadata): - raise ValueError("metadata() must return CollectorMetadata") + raise ValueError( + "metadata() must return CollectorMetadata" + ) + if declared_dependencies != metadata.dependencies: - raise ValueError("dependencies() must match metadata.dependencies") + raise ValueError( + "dependencies() must match metadata.dependencies" + ) + collector = collector_type() - with tempfile.TemporaryDirectory(prefix="logicytics-validation-") as temporary: + + with tempfile.TemporaryDirectory( + prefix="logicytics-validation-" + ) as temporary: workspace = Path(temporary) temporary_directory = workspace / "tmp" temporary_directory.mkdir() + context = CollectorContext( run_id="validation", collector_id=metadata.id, @@ -154,13 +205,20 @@ def _validate_contract(collector_type: type[Collector], kind: CollectorKind) -> settings={}, cancellation_file=workspace / "cancelled", ) + try: with _ValidationSideEffectGuard(): validation = collector.validate(context) except Exception as error: - raise ValueError(f"validate() probe failed: {error}") from error + raise ValueError( + f"validate() probe failed: {error}" + ) from error + if not isinstance(validation, ValidationResult): - raise ValueError("validate() must return ValidationResult") + raise ValueError( + "validate() must return ValidationResult" + ) + return metadata From 10da30f03899cdf45ab1c3eecb9ba2d3c37352aa Mon Sep 17 00:00:00 2001 From: Shahm Najeeb Date: Fri, 4 Sep 2026 15:26:37 +0300 Subject: [PATCH 218/316] fix: PEP warns about error in string formatting Signed-off-by: DefinetlyNotAI --- core/process/memory_map.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/core/process/memory_map.py b/core/process/memory_map.py index e3b693ee..1ffa3ead 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -101,12 +101,29 @@ def collect(self, context: CollectorContext) -> CollectorResult: base = ctypes.cast(memory.BaseAddress, ctypes.c_void_p).value or address readable = memory.State == 0x1000 and not (memory.Protect & 0x101) if readable: + base_address = int(base) + mapped = ctypes.create_unicode_buffer(32_768) - mapped_length = psapi.GetMappedFileNameW(process, ctypes.c_void_p(base), mapped, len(mapped)) - regions.append({"index": len(regions), "address": f"0x{base:016X}", "size_bytes": memory.RegionSize, - "rss_bytes": counters.WorkingSetSize, "permissions": _permissions(memory.Protect), - "mapped_path": mapped.value if mapped_length else None, "state": memory.State, - "type": memory.Type}) + mapped_length = psapi.GetMappedFileNameW( + process, + ctypes.c_void_p(base_address), + mapped, + len(mapped), + ) + + regions.append( + { + "index": len(regions), + "address": f"0x{base_address:016X}", + "size_bytes": memory.RegionSize, + "rss_bytes": counters.WorkingSetSize, + "permissions": _permissions(memory.Protect), + "mapped_path": mapped.value if mapped_length else None, + "state": memory.State, + "type": memory.Type, + } + ) + next_address = base + memory.RegionSize if next_address <= address: break From 8fb90aee855a0ae365f148041516491a87971688 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:37:41 +0300 Subject: [PATCH 219/316] fix: PEP warns about types for some part of the code (wip) Signed-off-by: DefinetlyNotAI --- logicytics/cli.py | 637 +++++++++++++++++++++++++++++++------- logicytics/maintenance.py | 12 +- 2 files changed, 541 insertions(+), 108 deletions(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index 7e3530a5..2d3e76b8 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -10,6 +10,7 @@ import platform import sys from pathlib import Path +from typing import Literal from logicytics.configuration import AppConfig, load_config from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest @@ -52,71 +53,152 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: flag: getattr(arguments, flag, False) for flag in LEGACY_MODE_ALIASES } + mode = resolve_execution_mode(getattr(arguments, "mode", None), legacy_flags) explicit_profile = getattr(arguments, "profile", None) + if mode is not None and explicit_profile is not None: raise ValueError("--profile cannot be combined with a named collection mode") + profile = mode.profile if mode is not None else explicit_profile or "standard" + explicit_sequential = getattr(arguments, "sequential", False) explicit_parallel = getattr(arguments, "parallel", False) + if mode is not None: if explicit_sequential and mode.strategy is ExecutionStrategy.PARALLEL: if legacy_flags["threaded"]: - raise ValueError("sequential execution conflicts with legacy --threaded mode") - raise ValueError(f"{mode.name} mode requires configured parallel execution") + raise ValueError( + "sequential execution conflicts with legacy --threaded mode" + ) + raise ValueError( + f"{mode.name} mode requires configured parallel execution" + ) + if explicit_parallel and mode.strategy is ExecutionStrategy.SEQUENTIAL: if legacy_flags["performance_check"] or legacy_flags["default_mode"]: raise ValueError( "parallel execution conflicts with sequential performance/default mode" ) raise ValueError(f"{mode.name} mode requires sequential execution") + sequential = explicit_sequential or ( mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL ) parallel = explicit_parallel or ( mode is not None and mode.strategy is ExecutionStrategy.PARALLEL ) + performance_check = mode.performance_check if mode is not None else False + enable_mods = getattr(arguments, "mods", False) or ( mode.enable_mods if mode is not None else False ) + non_python_only = mode.non_python_only if mode is not None else False + if sequential and arguments.workers is not None and arguments.workers != 1: raise ValueError("sequential execution requires --workers=1") + worker_count = 1 if sequential else arguments.workers or default_workers + if parallel and worker_count < 2: - raise ValueError("parallel execution requires at least two configured workers") + raise ValueError( + "parallel execution requires at least two configured workers" + ) + parent_run_id: str | None = None - if rerun_path := getattr(arguments, "rerun_from", None): + + rerun_value = getattr(arguments, "rerun_from", None) + + if rerun_value is not None: + if not isinstance(rerun_value, (str, os.PathLike)): + raise TypeError( + f"rerun_from must be a path-like value, got {type(rerun_value).__name__}" + ) + + rerun_path = Path(rerun_value) + if not arguments.include: - raise ValueError("--rerun-from requires at least one explicit --include collector ID") - manifest_path = rerun_path / "manifest.json" if rerun_path.is_dir() else rerun_path + raise ValueError( + "--rerun-from requires at least one explicit --include collector ID" + ) + + manifest_path = ( + rerun_path / "manifest.json" + if rerun_path.is_dir() + else rerun_path + ) + try: - previous = json.loads(manifest_path.read_text(encoding="utf-8")) + previous = json.loads( + manifest_path.read_text(encoding="utf-8") + ) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - raise ValueError(f"original run manifest cannot be loaded: {error}") from error - if not isinstance(previous, dict) or not isinstance(previous.get("run_id"), str): - raise ValueError("original run manifest must contain a valid run_id") + raise ValueError( + f"original run manifest cannot be loaded: {error}" + ) from error + + if not isinstance(previous, dict) or not isinstance( + previous.get("run_id"), + str, + ): + raise ValueError( + "original run manifest must contain a valid run_id" + ) + manifest_schema_version = previous.get("manifest_schema_version") + if ( not isinstance(manifest_schema_version, int) or isinstance(manifest_schema_version, bool) or manifest_schema_version != MANIFEST_SCHEMA_VERSION ): + schema_version_display = ( + "None" + if manifest_schema_version is None + else repr(manifest_schema_version) + ) + + raise ValueError( + f"original run manifest uses unsupported schema_version " + f"{schema_version_display}; expected {MANIFEST_SCHEMA_VERSION}" + ) + + if previous.get("status") not in { + "succeeded", + "partial", + "failed", + "cancelled", + }: raise ValueError( - f"original run manifest uses unsupported schema_version {manifest_schema_version!r}; " - f"expected {MANIFEST_SCHEMA_VERSION}" + "original run manifest must describe a finalized run" ) - if previous.get("status") not in {"succeeded", "partial", "failed", "cancelled"}: - raise ValueError("original run manifest must describe a finalized run") + resolved = previous.get("resolved_plan") - if not isinstance(resolved, list) or not all(isinstance(item, str) for item in resolved): - raise ValueError("original run manifest must contain a valid resolved_plan") - unknown = sorted(set(arguments.include) - set(resolved)) + + if not isinstance(resolved, list) or not all( + isinstance(item, str) + for item in resolved + ): + raise ValueError( + "original run manifest must contain a valid resolved_plan" + ) + + unknown = sorted( + set(arguments.include) - set(resolved) + ) + if unknown: - raise ValueError(f"rerun collectors were not present in the original run: {', '.join(unknown)}") + raise ValueError( + "rerun collectors were not present in the original run: " + f"{', '.join(unknown)}" + ) + parent_run_id = previous["run_id"] + selection_only = getattr(arguments, "command", None) == "collector" + if selection_only and ( arguments.include or arguments.exclude @@ -124,8 +206,16 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: or arguments.plugins or arguments.mods ): - raise ValueError("collector execution cannot combine its ID with profile or selection flags") - includes = (arguments.collector_id,) if selection_only else tuple(arguments.include) + raise ValueError( + "collector execution cannot combine its ID with profile or selection flags" + ) + + includes = ( + (arguments.collector_id,) + if selection_only + else tuple(arguments.include) + ) + return RunRequest( profile=profile, include=includes, @@ -135,8 +225,15 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: enable_mods=enable_mods, non_python_only=non_python_only, max_workers=worker_count, - acknowledge_authorization=getattr(arguments, "acknowledge_authorization", False), - approved_capabilities=tuple(Capability(value) for value in arguments.allow_capability), + acknowledge_authorization=getattr( + arguments, + "acknowledge_authorization", + False, + ), + approved_capabilities=tuple( + Capability(value) + for value in arguments.allow_capability + ), performance_check=performance_check, rerun_from=parent_run_id, output_policy=( @@ -362,216 +459,543 @@ def _status_marker(label: str, count: int) -> str: def _run_developer_action( - root: Path, configuration: AppConfig, arguments: argparse.Namespace + root: Path, + configuration: AppConfig, + arguments: argparse.Namespace, ) -> int: """Run read-only contribution checks and an explicitly confirmed manifest update.""" settings = configuration.maintenance existing = load_local_manifest(root, settings) + comparison = ( compare_files(root, settings, existing) if existing - else {"missing": [], "modified": [], "extra": [], "unchanged": []} + else { + "missing": [], + "modified": [], + "extra": [], + "unchanged": [], + } ) + checks = developer_checks(root, settings) next_version = arguments.next_version write_requested = arguments.write_manifest + if arguments.interactive: print("Contribution and repository organization checks") + for name in ("missing", "modified", "extra", "unchanged"): print(_status_marker(name.title(), len(comparison[name]))) - organization_checks = ( + + organization_checks: tuple[ + Literal[ + "naming_violations", + "misplaced_python", + "missing_module_docstrings", + "crowded_modules", + ], + ..., + ] = ( "naming_violations", "misplaced_python", "missing_module_docstrings", "crowded_modules", ) + for name in organization_checks: - print(_status_marker(name.replace("_", " ").title(), len(checks[name]))) + print( + _status_marker( + name.replace("_", " ").title(), + len(checks[name]), + ) + ) + if next_version is None: - entered = input(f"Next semantic version [{local_version(root)}]: ").strip() - next_version = entered or local_version(root) - answer = input("Write the reviewed integrity manifest? [y/N]: ").strip().casefold() + current_version = local_version(root) + entered = input( + f"Next semantic version [{current_version}]: " + ).strip() + next_version = entered or current_version + + answer = input( + "Write the reviewed integrity manifest? [y/N]: " + ).strip().casefold() + write_requested = answer in {"y", "yes"} + if arguments.write_manifest and next_version is None: - raise ValueError("--write-manifest requires --next-version unless --interactive is used") + raise ValueError( + "--write-manifest requires --next-version unless " + "--interactive is used" + ) + manifest_path: str | None = None + if write_requested: if next_version is None: - raise ValueError("a semantic next version is required to write the integrity manifest") + raise ValueError( + "a semantic next version is required to write " + "the integrity manifest" + ) + manifest = build_manifest(root, settings, next_version) legacy_ini = root / "CODE" / "config.ini" modern_config = root / "logicytics.json" - manifest_path = str( - write_legacy_ini_manifest(root, manifest) - if legacy_ini.is_file() and not modern_config.exists() - else write_local_manifest(root, settings, manifest) - ) + + if legacy_ini.is_file() and not modern_config.exists(): + written_manifest = write_legacy_ini_manifest(root, manifest) + else: + written_manifest = write_local_manifest( + root, + settings, + manifest, + ) + + manifest_path = str(written_manifest) + payload = { "checks": checks, "comparison": comparison, "current_version": local_version(root), - "existing_manifest_version": existing.version if existing else None, + "existing_manifest_version": ( + existing.version if existing else None + ), "manifest_written": manifest_path, "next_version": next_version, } + print(json.dumps(payload, indent=2, sort_keys=True)) return 0 def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" - parser = parser() - arguments = parser.parse_args(argv) - standalone_actions = sum(bool(value) for value in (arguments.usage, arguments.match, arguments.modes)) + cli_parser = parser() + arguments = cli_parser.parse_args(argv) + + standalone_actions = sum( + bool(value) + for value in ( + arguments.usage, + arguments.match, + arguments.modes, + ) + ) + if standalone_actions > 1: - parser.error("--usage, --match, and --modes are mutually exclusive") + cli_parser.error( + "--usage, --match, and --modes are mutually exclusive" + ) + if arguments.command is not None and standalone_actions: - parser.error("--usage, --match, and --modes are standalone actions") + cli_parser.error( + "--usage, --match, and --modes are standalone actions" + ) + if arguments.usage: arguments.command = "usage" elif arguments.match: arguments.command = "match" elif arguments.modes: arguments.command = "modes" - if arguments.command is None: - parser.print_help() - return 0 + root = _project_root() application_logger = None + try: configuration = load_config(root, arguments.config) - layout = ensure_output_layout(configuration.runtime.output_root) + + layout = ensure_output_layout( + configuration.runtime.output_root + ) + application_logger = get_application_logger( layout.application_log, configuration.logging, ) - application_logger.event("INFO", "command_started", command=arguments.command) - history_path = configuration.runtime.output_root / "interaction_history.json.gz" + + application_logger.event( + "INFO", + "command_started", + command=arguments.command, + ) + + history_path = ( + configuration.runtime.output_root + / "interaction_history.json.gz" + ) + if arguments.command == "match": history = load_history(history_path) + match = match_flag( arguments.match, - threshold=configuration.interaction.similarity_threshold, + threshold=( + configuration.interaction.similarity_threshold + ), model_name=configuration.interaction.model_name, history=history, ) + if configuration.interaction.history_enabled: record_match(history_path, match) - payload = { + + payload: dict[str, object] = { "input": match.input, "matched_flag": match.matched_flag, "accuracy": match.accuracy, "source": match.source, - "history_persisted": configuration.interaction.history_enabled, + "history_persisted": ( + configuration.interaction.history_enabled + ), } + if configuration.interaction.model_debug: - payload["model_debug"] = {"model_name": match.model_name, - "threshold": configuration.interaction.similarity_threshold} - print(json.dumps(payload, indent=2, sort_keys=True)) + payload["model_debug"] = { + "model_name": match.model_name, + "threshold": ( + configuration.interaction.similarity_threshold + ), + } + + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) + return 0 if match.matched_flag is not None else 1 + if arguments.command == "usage": - statistics = usage_statistics(load_history(history_path)) - graph_path = write_usage_graph(configuration.runtime.output_root / "flag_usage.svg", statistics) - print(json.dumps({**statistics, "graph_path": str(graph_path)}, indent=2, sort_keys=True)) + statistics = usage_statistics( + load_history(history_path) + ) + + graph_path = write_usage_graph( + configuration.runtime.output_root + / "flag_usage.svg", + statistics, + ) + + payload: dict[str, object] = { + **statistics, + "graph_path": str(graph_path), + } + + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) + return 0 + if arguments.command == "modes": - report = preflight(root, configuration_hash=configuration.fingerprint()) - print(json.dumps(mode_matrix((*report.valid, *report.invalid)), indent=2, sort_keys=True)) + report = preflight( + root, + configuration_hash=configuration.fingerprint(), + ) + + payload = mode_matrix( + (*report.valid, *report.invalid) + ) + + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) + return 0 - report = preflight(root, configuration_hash=configuration.fingerprint()) + + report = preflight( + root, + configuration_hash=configuration.fingerprint(), + ) + if arguments.command == "preflight": validation = report.to_dict( selected_plugins=tuple(arguments.include), enable_plugins=arguments.plugins, enable_mods=arguments.mods, ) - payload = { + + payload: dict[str, object] = { "environment": inspect_environment().to_dict(), "sysinternals": ensure_sysinternals(root).to_dict(), **validation, } - print(json.dumps(payload, indent=2, sort_keys=True)) + + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) + return 0 if not validation["invalid"] else 2 + if arguments.command == "debug": - payload = { - "configuration": configuration.to_manifest_dict(), + payload: dict[str, object] = { + "configuration": ( + configuration.to_manifest_dict() + ), "environment": inspect_environment().to_dict(), - "python": {"executable": sys.executable, "implementation": platform.python_implementation(), - "version": platform.python_version(), "prefix": sys.prefix, - "virtual_environment": sys.prefix != sys.base_prefix, - "psutil_available": importlib.util.find_spec("psutil") is not None, - "cpu_count": os.cpu_count()}, - "sysinternals": ensure_sysinternals(root).to_dict(), - "preflight": {"valid_collectors": len(report.valid), "invalid_collectors": len(report.invalid)}, - "maintenance": maintenance_diagnostics(root, configuration.maintenance), + "python": { + "executable": sys.executable, + "implementation": ( + platform.python_implementation() + ), + "version": platform.python_version(), + "prefix": sys.prefix, + "virtual_environment": ( + sys.prefix != sys.base_prefix + ), + "psutil_available": ( + importlib.util.find_spec("psutil") + is not None + ), + "cpu_count": os.cpu_count(), + }, + "sysinternals": ( + ensure_sysinternals(root).to_dict() + ), + "preflight": { + "valid_collectors": len(report.valid), + "invalid_collectors": len(report.invalid), + }, + "maintenance": maintenance_diagnostics( + root, + configuration.maintenance, + ), } - debug_path = layout.debug_logs / "debug.json" + + debug_path = ( + layout.debug_logs + / "debug.json" + ) + payload["debug_log"] = str(debug_path) - _write_json(debug_path, payload) - print(json.dumps(payload, indent=2, sort_keys=True)) + + _write_json( + debug_path, + payload, + ) + + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) + return 0 if not report.invalid else 2 + if arguments.command == "update": - if arguments.new_window != (arguments.launch_action is not None): - raise ValueError("--new-window and --launch-action must be provided together") + if arguments.new_window != ( + arguments.launch_action is not None + ): + raise ValueError( + "--new-window and --launch-action " + "must be provided together" + ) + git = process_adapter.run( - ["git", "--version"], capture_output=True, check=False, text=True + ["git", "--version"], + capture_output=True, + check=False, + text=True, ) + is_repository = (root / ".git").exists() - payload = {"git_available": git.returncode == 0, "git_version": git.stdout.strip() or None, - "is_repository": is_repository, "applied": False} + + payload: dict[str, object] = { + "git_available": git.returncode == 0, + "git_version": ( + git.stdout.strip() or None + ), + "is_repository": is_repository, + "applied": False, + } + + pull_returncode: int | None = None + if arguments.apply: if git.returncode != 0 or not is_repository: - print(json.dumps(payload, indent=2, sort_keys=True)) + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) return 2 + pulled = process_adapter.run( - ["git", "pull"], cwd=root, capture_output=True, check=False, text=True + ["git", "pull"], + cwd=root, + capture_output=True, + check=False, + text=True, + ) + + pull_returncode = pulled.returncode + + payload.update( + { + "applied": True, + "returncode": pulled.returncode, + "stdout": pulled.stdout, + "stderr": pulled.stderr, + } ) - payload.update({"applied": True, "returncode": pulled.returncode, "stdout": pulled.stdout, - "stderr": pulled.stderr}) - update_succeeded = not arguments.apply or payload.get("returncode") == 0 + + update_succeeded = ( + not arguments.apply + or pull_returncode == 0 + ) + if arguments.new_window and update_succeeded: - payload["launched_action"] = arguments.launch_action - payload["launched_process_id"] = launch_action_window(root, arguments.launch_action) - print(json.dumps(payload, indent=2, sort_keys=True)) + launched_process_id = launch_action_window( + root, + arguments.launch_action, + ) + + payload["launched_action"] = ( + arguments.launch_action + ) + payload["launched_process_id"] = ( + launched_process_id + ) + + print( + json.dumps( + payload, + indent=2, + sort_keys=True, + ) + ) + return 0 if update_succeeded else 1 + if arguments.command == "dev": - return _run_developer_action(root, configuration, arguments) - plan = build_plan(report, request(arguments, configuration.runtime.default_max_workers)) + return _run_developer_action( + root, + configuration, + arguments, + ) + + plan = build_plan( + report, + request( + arguments, + configuration.runtime.default_max_workers, + ), + ) + if arguments.command == "plan": - print("\n".join(candidate.metadata.id for candidate in plan.collectors if candidate.metadata)) + print( + "\n".join( + candidate.metadata.id + for candidate in plan.collectors + if candidate.metadata is not None + ) + ) + return 0 - outcome = RunSupervisor(root, configuration).run(plan) + + outcome = RunSupervisor( + root, + configuration, + ).run(plan) + print("Collectors:") + for record in outcome.manifest.collectors: - duration = "not-started" if record.duration_seconds is None else f"{record.duration_seconds:.3f}" + duration = ( + "not-started" + if record.duration_seconds is None + else f"{record.duration_seconds:.3f}" + ) + print( - f"- {record.id} status={record.status} duration_seconds={duration} " + f"- {record.id} " + f"status={record.status} " + f"duration_seconds={duration} " f"summary={record.summary or 'not-finished'}" ) + if record.failure is not None: print( - f" failure operation={record.failure['operation']} " - f"retry_safe={str(record.failure['retry_safe']).lower()} " - f"platform_error={record.failure['platform_error']} " - f"remediation={record.failure['remediation']}" + f" failure " + f"operation={record.failure['operation']} " + f"retry_safe=" + f"{str(record.failure['retry_safe']).lower()} " + f"platform_error=" + f"{record.failure['platform_error']} " + f"remediation=" + f"{record.failure['remediation']}" ) + if plan.request.performance_check: - performance_path = outcome.run_directory / "logs" / "performance.json" - print(f"Performance: {performance_path}") - if outcome.manifest.package and "path" in outcome.manifest.package: + performance_path = ( + outcome.run_directory + / "logs" + / "performance.json" + ) + print( - f"Package: {outcome.manifest.package['path']}\n" - f"SHA-256: {outcome.manifest.package.get('sha256_path', 'unavailable')}" + f"Performance: {performance_path}" ) - print(f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}") - exit_code = 0 if outcome.manifest.status.value == "succeeded" else 1 + + if ( + outcome.manifest.package + and "path" in outcome.manifest.package + ): + print( + f"Package: " + f"{outcome.manifest.package['path']}\n" + f"SHA-256: " + f"{outcome.manifest.package.get( + 'sha256_path', + 'unavailable', + )}" + ) + + print( + f"Run: {outcome.manifest_path}\n" + f"Status: {outcome.manifest.status.value}" + ) + + exit_code = ( + 0 + if outcome.manifest.status.value == "succeeded" + else 1 + ) + if arguments.interactive: try: input("Press Enter to exit...") except EOFError: pass + return exit_code - except (LogicyticsError, OSError, PermissionError, ValueError) as error: + + except ( + LogicyticsError, + OSError, + PermissionError, + ValueError, + ) as error: if application_logger is not None: with contextlib.suppress(OSError): application_logger.event( @@ -579,6 +1003,7 @@ def main(argv: list[str] | None = None) -> int: str(error), error_type=type(error).__name__, ) + print(f"Error: {error}") return 2 diff --git a/logicytics/maintenance.py b/logicytics/maintenance.py index 3acaf06b..760113f9 100644 --- a/logicytics/maintenance.py +++ b/logicytics/maintenance.py @@ -12,7 +12,7 @@ import urllib.request from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Any, Mapping +from typing import Any, Mapping, TypedDict from logicytics.configuration import MaintenanceSettings @@ -356,7 +356,15 @@ def maintenance_diagnostics( } -def developer_checks(project_root: Path, settings: MaintenanceSettings) -> dict[str, object]: +class DeveloperChecks(TypedDict): + naming_violations: list[str] + misplaced_python: list[str] + missing_module_docstrings: list[str] + crowded_modules: list[str] + eligible_file_count: int + + +def developer_checks(project_root: Path, settings: MaintenanceSettings) -> DeveloperChecks: """Inspect repository organization without importing or executing project modules.""" naming: list[str] = [] missing_docstrings: list[str] = [] From 84e3b3f64919c865cdf1bda49b5f8c3baaccf449 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:40:21 +0300 Subject: [PATCH 220/316] fix: PEP warns about function calls that dont return anything in tests Signed-off-by: DefinetlyNotAI --- tests/test_collector_cancellation.py | 5 ++--- tests/test_core_functionality.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index 67c92dae..60a2c9cf 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -159,10 +159,9 @@ def cancel_system_copy(source: Path, destination: Path) -> None: ssh_context.workspace.mkdir() original_write = ssh_backup.zipfile.ZipFile.write - def cancel_archive_write(archive, *args, **kwargs): - result = original_write(archive, *args, **kwargs) + def cancel_archive_write(archive, *args: object, **kwargs: object) -> None: + original_write(archive, *args, **kwargs) ssh_context.cancelled = True - return result with patch.object(ssh_backup.filesystem_adapter, "home", return_value=home), patch.object( ssh_backup.zipfile.ZipFile, "write", new=cancel_archive_write): diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 8c08a58a..06f43d3b 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -2746,7 +2746,7 @@ def test_preflight_plan_run_and_package(self) -> None: def capture_manifest_state(path, manifest): persisted_states.append(manifest.status.value) - return write_manifest(path, manifest) + write_manifest(path, manifest) with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest_state): outcome = RunSupervisor(root, configuration).run(plan) From f765ebbd4fc8f826fc85e5125ae455b42b4f2647 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:43:07 +0300 Subject: [PATCH 221/316] fix: PEP warns about unused func params Signed-off-by: DefinetlyNotAI --- logicytics/contracts.py | 8 +++++--- tests/test_platform_adapters.py | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index a61eb17a..cd15f00c 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -610,7 +610,7 @@ def metadata(cls) -> CollectorMetadata: def validate(self, context: CollectorContext) -> ValidationResult: """Validate prerequisites without creating evidence artifacts.""" - def prepare(self, context: CollectorContext) -> ValidationResult: + def prepare(self, _context: CollectorContext) -> ValidationResult: """Prepare collector-local resources after validation and authorization.""" return ValidationResult(True) @@ -618,11 +618,13 @@ def prepare(self, context: CollectorContext) -> ValidationResult: def collect(self, context: CollectorContext) -> CollectorResult: """Collect evidence and return a normalized result.""" - def finalize(self, context: CollectorContext, result: CollectorResult) -> CollectorResult: + @staticmethod + def finalize(_context: CollectorContext, result: CollectorResult) -> CollectorResult: """Finalize collector-local evidence and return the publishable result.""" return result - def estimate(self, context: CollectorContext) -> CollectionEstimate: + @staticmethod + def estimate(_context: CollectorContext) -> CollectionEstimate: """Optionally estimate time and output without collecting evidence.""" return CollectionEstimate(estimated_seconds=0, estimated_output_bytes=0) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index f7820cfc..58cc80c2 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -21,7 +21,7 @@ class ProcessAdapterTests(unittest.TestCase): def test_process_adapter_normalizes_and_delegates_shell_free_commands(self) -> None: - def execute(command, *, stdout, stderr, **options): + def execute(command, *, stdout, stderr, **_options): stdout.write(b"output") stderr.write(b"warning") return subprocess.CompletedProcess(command, 7) @@ -43,7 +43,7 @@ def test_process_adapter_rejects_captured_streams_over_the_hard_limit(self) -> N adapter = ProcessAdapter() adapter.maximum_capture_bytes = 3 - def execute(command, *, stdout, stderr, **options): + def execute(command, *, stdout, _stderr, **_options): stdout.write(b"four") return subprocess.CompletedProcess(command, 0) From 5e0af380dbf80a632a91008469d8a1741a7dfba2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:44:07 +0300 Subject: [PATCH 222/316] fix: Simplified bool exp Signed-off-by: DefinetlyNotAI --- logicytics/packaging.py | 2 +- logicytics/platform_adapters.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 1a4967eb..0815c534 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -172,7 +172,7 @@ def _log_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[Path, if not path.is_file() or path.resolve(strict=True) != path: raise ValueError(f"diagnostic log escapes its run-owned event channel: {path.name}") sources.append((path, archive_name)) - if manifest.request.get("performance_check") is True: + if manifest.request.get("performance_check"): performance_path = root / "logs" / "performance.json" if not performance_path.is_file(): raise FileNotFoundError("requested performance report is missing") diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 534bd0c6..a06480be 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -39,7 +39,7 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" normalized = self._command(command) capture_directory = options.pop("capture_directory", None) - if options.get("shell") is True: + if options.get("shell"): raise ValueError("collector process adapters never permit shell execution") timeout = options.get("timeout") if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0): @@ -80,11 +80,12 @@ def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedPro def popen(self, command: Sequence[str], **options: Any) -> subprocess.Popen[Any]: """Start one explicit long-lived process without invoking a command shell.""" normalized = self._command(command) - if options.get("shell") is True: + if options.get("shell"): raise ValueError("process adapters never permit shell execution") return subprocess.Popen(normalized, **options) - def memory_bytes(self, process_id: int) -> int | None: + @staticmethod + def memory_bytes(process_id: int) -> int | None: """Return one process's resident memory through the host-specific boundary.""" if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: raise ValueError("process_id must be a positive integer") From 7b61d98f840faa40ec8951959d82ed93c9ebe0e2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:45:36 +0300 Subject: [PATCH 223/316] fix: align test artifact writer signature with base class Signed-off-by: DefinetlyNotAI --- tests/test_running_processes_collector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index 57aa4411..d7ed64b3 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -21,6 +21,7 @@ def register_file( *, media_type: str = "application/octet-stream", transformations: tuple[str, ...] = (), + evidence_kind: str | None = None, ): raise AssertionError("a skipped collector must not register artifacts") From 45f5ce97c5103b1d7b5fdf2c8f0512f527464aba Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:46:26 +0300 Subject: [PATCH 224/316] fix: invalid abstract class definitions without implementing all abstract methods (PEP warn) Signed-off-by: DefinetlyNotAI --- logicytics/contracts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index cd15f00c..00ef55b9 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -637,9 +637,9 @@ def cleanup(self, context: CollectorContext) -> None: """Release collector-local resources. The engine owns filesystem cleanup.""" -class CoreCollector(Collector): +class CoreCollector(Collector, ABC): """Marker base class for collectors shipped with Logicytics.""" -class PluginCollector(Collector): +class PluginCollector(Collector, ABC): """Marker base class for user-owned collectors discovered in plugins/.""" From 8f232ca116a8dc3bc077a317bdb8561fe38e345a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:48:34 +0300 Subject: [PATCH 225/316] fix: declared methods static when possible (PEP) Signed-off-by: DefinetlyNotAI --- logicytics/contracts.py | 3 ++- logicytics/platform_adapters.py | 3 ++- tests/test_collector_cancellation.py | 18 ++++++++++++------ tests/test_core_functionality.py | 9 ++++++--- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 00ef55b9..6ddff218 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -610,7 +610,8 @@ def metadata(cls) -> CollectorMetadata: def validate(self, context: CollectorContext) -> ValidationResult: """Validate prerequisites without creating evidence artifacts.""" - def prepare(self, _context: CollectorContext) -> ValidationResult: + @staticmethod + def prepare(_context: CollectorContext) -> ValidationResult: """Prepare collector-local resources after validation and authorization.""" return ValidationResult(True) diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index a06480be..827e415a 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -143,7 +143,8 @@ def EnumKey(self, key: Any, index: int) -> str: def EnumValue(self, key: Any, index: int): return self._module().EnumValue(key, index) - def last_write_time(self, key: Any) -> str | None: + @staticmethod + def last_write_time(key: Any) -> str | None: """Return one key's Win32 last-write timestamp in UTC when available.""" if _winreg is None: return None diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index 60a2c9cf..cc71b3b8 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -37,7 +37,8 @@ def __init__(self, workspace: Path, settings: dict[str, object] | None = None) - def is_cancelled(self) -> bool: return self.cancelled - def report_progress(self, _event: str, **_metrics: int | float | str) -> None: + @staticmethod + def report_progress(_event: str, **_metrics: int | float | str) -> None: return None @@ -178,16 +179,20 @@ def test_packet_and_memory_loops_stop_before_serialization(self) -> None: packet_context.workspace.mkdir() class FakeSocket: - def bind(self, _address) -> None: + @staticmethod + def bind(_address) -> None: return None - def setsockopt(self, *_args) -> None: + @staticmethod + def setsockopt(*_args) -> None: return None - def ioctl(self, *_args) -> None: + @staticmethod + def ioctl(*_args) -> None: return None - def close(self) -> None: + @staticmethod + def close() -> None: return None def cancel_wait(*_args): @@ -206,7 +211,8 @@ def cancel_wait(*_args): memory_context.workspace.mkdir() class FakeKernel: - def GetCurrentProcess(self): + @staticmethod + def GetCurrentProcess(): memory_context.cancelled = True return 1 diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index 06f43d3b..e85a3182 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1530,7 +1530,8 @@ def test_deprecation_decorator_logs_removal_context(self) -> None: events: list[tuple[str, str, dict[str, object]]] = [] class Logger: - def event(self, level: str, message: str, **fields: object) -> None: + @staticmethod + def event(level: str, message: str, **fields: object) -> None: events.append((level, message, fields)) @deprecated(Logger(), removal_version="5.0", reason="replacement exists") @@ -1546,7 +1547,8 @@ def test_exception_helper_logs_before_raising(self) -> None: events: list[tuple[str, str, dict[str, object]]] = [] class Logger: - def event(self, level: str, message: str, **fields: object) -> None: + @staticmethod + def event(level: str, message: str, **fields: object) -> None: events.append((level, message, fields)) with self.assertRaises(ValueError): @@ -1559,7 +1561,8 @@ def test_timed_decorator_records_function_lifecycle(self) -> None: events: list[tuple[str, str, dict[str, object]]] = [] class Logger: - def event(self, level: str, message: str, **fields: object) -> None: + @staticmethod + def event(level: str, message: str, **fields: object) -> None: events.append((level, message, fields)) @timed(Logger()) From bab5d13f9499187092f944acead2c46f0f4ed112 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 15:55:28 +0300 Subject: [PATCH 226/316] style: fixed hanging indents Signed-off-by: DefinetlyNotAI --- core/browser/browser_data_backup.py | 16 +++++--- logicytics/api.py | 20 +++++++--- logicytics/discovery.py | 35 ++++++++++------- logicytics/logging.py | 14 +++++-- logicytics/maintenance.py | 8 +++- logicytics/planner.py | 10 +++-- logicytics/runtime.py | 40 ++++++++++++++------ tests/test_shipped_collectors.py | 58 ++++++++++++++++++++--------- 8 files changed, 137 insertions(+), 64 deletions(-) diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index 5ac40510..7a31aadc 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -70,11 +70,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("browser_data_backup_started") for browser, root in chromium_roots: try: - profile_roots = [root] if browser.startswith("opera") else [path for path in - filesystem_adapter.children(root) if - path.is_dir() and ( - path.name == "Default" or path.name.startswith( - "Profile "))] + if browser.startswith("opera"): + profile_roots = [root] + else: + profile_roots = [ + path + for path in filesystem_adapter.children(root) + if path.is_dir() and ( + path.name == "Default" + or path.name.startswith("Profile ") + ) + ] except OSError: continue for profile in profile_roots: diff --git a/logicytics/api.py b/logicytics/api.py index be72d189..4b987b6e 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -203,15 +203,23 @@ def _collector_snapshot(record: dict[str, Any], collector_id: str, status: str) required = {"collector_id", "operation", "platform_error", "remediation", "retry_safe"} if not isinstance(raw_failure, dict) or set(raw_failure) != required: raise PlanError("run manifest collector failure must contain the complete actionable failure contract") - if ( - raw_failure.get("collector_id") != collector_id - or not all( + actionable_fields = ("operation", "platform_error", "remediation") + + has_valid_collector_id = raw_failure.get("collector_id") == collector_id + has_valid_actionable_details = all( isinstance(raw_failure.get(field), str) and raw_failure[field].strip() - for field in ("operation", "platform_error", "remediation") + for field in actionable_fields ) - or not isinstance(raw_failure.get("retry_safe"), bool) + has_valid_retry_flag = isinstance(raw_failure.get("retry_safe"), bool) + + if not ( + has_valid_collector_id + and has_valid_actionable_details + and has_valid_retry_flag ): - raise PlanError("run manifest collector failure contains invalid actionable details") + raise PlanError( + "run manifest collector failure contains invalid actionable details" + ) failure = CollectorFailureSnapshot( collector_id=collector_id, operation=raw_failure["operation"], diff --git a/logicytics/discovery.py b/logicytics/discovery.py index ef8ba00d..8f300aa2 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -444,20 +444,29 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat allowed = { "metadata", "validate", "prepare", "collect", "finalize", "cleanup", "estimate", "dependencies", } + metadata_calls: list[ast.Call] = [] - if "metadata" in methods: - metadata_calls = [ - node for node in ast.walk(methods["metadata"]) - if isinstance(node, ast.Call) - and ( - isinstance(node.func, ast.Name) and node.func.id == "CollectorMetadata" - or isinstance(node.func, ast.Attribute) and node.func.attr == "CollectorMetadata" - ) - ] - if len(metadata_calls) != 1: - candidate.static_errors.append( - "metadata must construct one CollectorMetadata object directly" - ) + + for node in ast.walk(methods["metadata"]): + if not isinstance(node, ast.Call): + continue + + is_metadata_constructor = ( + isinstance(node.func, ast.Name) + and node.func.id == "CollectorMetadata" + ) or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "CollectorMetadata" + ) + + if is_metadata_constructor: + metadata_calls.append(node) + + if len(metadata_calls) != 1: + candidate.static_errors.append( + "metadata must construct one CollectorMetadata object directly" + ) + if candidate.kind is CollectorKind.PLUGIN and "metadata" in methods: required_plugin_fields = { "capabilities", "privilege_level", "sensitive_data_categories", "network_access", diff --git a/logicytics/logging.py b/logicytics/logging.py index 10ab0013..f0b5c018 100644 --- a/logicytics/logging.py +++ b/logicytics/logging.py @@ -192,8 +192,10 @@ def get_event_logger(path: Path, *, run_id: str, collector_id: str | None = None return logger -def timed(logger: EventLogger, *, level: str = "info") -> Callable[ - [Callable[Parameters, Result]], Callable[Parameters, Result]]: +def timed( + logger: EventLogger, *, + level: str = "info" +) -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so structured start, finish, error, and duration events are written.""" def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: @@ -222,8 +224,12 @@ def raise_logged(logger: EventLogger, exception_type: type[Exception], message: raise exception_type(message) -def deprecated(logger: EventLogger, *, removal_version: str, reason: str, include_stack: bool = False) -> Callable[ - [Callable[Parameters, Result]], Callable[Parameters, Result]]: +def deprecated( + logger: EventLogger, *, + removal_version: str, + reason: str, + include_stack: bool = False +) -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so each invocation emits a structured deprecation warning.""" def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: diff --git a/logicytics/maintenance.py b/logicytics/maintenance.py index 760113f9..465e66b6 100644 --- a/logicytics/maintenance.py +++ b/logicytics/maintenance.py @@ -386,13 +386,17 @@ def developer_checks(project_root: Path, settings: MaintenanceSettings) -> Devel continue if ast.get_docstring(tree) is None: missing_docstrings.append(relative.as_posix()) + public_features = [ node.name for node in tree.body - if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) - and not node.name.startswith("_") + if isinstance( + node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) + ) and not node.name.startswith("_") ] + if len(public_features) > 12: crowded_modules.append(relative.as_posix()) + return { "naming_violations": sorted(naming), "misplaced_python": sorted(misplaced_python), diff --git a/logicytics/planner.py b/logicytics/planner.py index 2bd2e359..dcbcf716 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -103,10 +103,12 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: invalid_selected = [ candidate for candidate in report.invalid - if candidate.kind is CollectorKind.CORE - or candidate.selection_id in request.include - or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) - or (request.enable_mods and candidate.kind is CollectorKind.MOD) + if ( + candidate.kind is CollectorKind.CORE or + candidate.selection_id in request.include or + (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) or + (request.enable_mods and candidate.kind is CollectorKind.MOD) + ) ] if invalid_selected: details = "; ".join( diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 1480f853..98b1c4c5 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -852,22 +852,38 @@ def _supervise( ) write_manifest(manifest_path, manifest) continue + if any(status != "succeeded" for status in dependency_states.values()): break + if active and not candidate.metadata.parallel_safe: break - if active and ( - candidate.metadata.resource_class is ResourceClass.INTERACTIVE - or any(worker.resource_class is ResourceClass.INTERACTIVE for worker in active.values()) - or ( - candidate.metadata.resource_class is not ResourceClass.GENERAL - and any( - worker.resource_class is candidate.metadata.resource_class - for worker in active.values() - ) - ) - ): - break + + if active: + candidate_class = candidate.metadata.resource_class + + has_interactive_worker = any( + worker.resource_class is ResourceClass.INTERACTIVE + for worker in active.values() + ) + + has_same_resource_class = any( + worker.resource_class is candidate_class + for worker in active.values() + ) + + conflicts_with_active_worker = ( + candidate_class is ResourceClass.INTERACTIVE + or has_interactive_worker + or ( + candidate_class is not ResourceClass.GENERAL + and has_same_resource_class + ) + ) + + if conflicts_with_active_worker: + break + reserved_output_bytes = sum(worker.reserved_output_bytes for worker in active.values()) remaining_output_bytes = ( self.configuration.runtime.maximum_run_output_bytes diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 7fc2a71c..f8ac04a5 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -269,19 +269,31 @@ def test_every_registered_artifact_media_type_is_declared(self) -> None: if candidate.kind.value != "core" or candidate.metadata is None: continue tree = ast.parse(candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path)) - register_calls = [ - node for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Attribute) - and node.func.attr == "register_file" - ] - declared_at_calls = { - keyword.value.value - for node in register_calls for keyword in node.keywords - if keyword.arg == "media_type" - and isinstance(keyword.value, ast.Constant) - and isinstance(keyword.value.value, str) - } + + register_calls: list[ast.Call] = [] + + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + + if ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "register_file" + ): + register_calls.append(node) + + declared_at_calls: set[str] = set() + + for call in register_calls: + for keyword in call.keywords: + if keyword.arg != "media_type": + continue + + value = keyword.value + + if isinstance(value, ast.Constant) and isinstance(value.value, str): + declared_at_calls.add(value.value) + if any(not any(keyword.arg == "media_type" for keyword in node.keywords) for node in register_calls): declared_at_calls.add("application/octet-stream") @@ -316,11 +328,21 @@ def test_each_core_module_owns_exactly_one_policy_contract(self) -> None: if candidate.kind.value != "core" or candidate.metadata is None: continue tree = ast.parse(candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path)) - collector_classes = [ - node for node in tree.body - if isinstance(node, ast.ClassDef) - and any(ast.unparse(base).endswith("CoreCollector") for base in node.bases) - ] + + collector_classes: list[ast.ClassDef] = [] + + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + + inherits_core_collector = any( + ast.unparse(base).endswith("CoreCollector") + for base in node.bases + ) + + if inherits_core_collector: + collector_classes.append(node) + with self.subTest(collector=candidate.metadata.id): self.assertEqual([candidate.expected_class], [node.name for node in collector_classes]) contract = core_output_contract(candidate.metadata) From 3e1f4c4e14f7fc23ddf439280ead8e60b20f6b33 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 4 Sep 2026 16:22:24 +0300 Subject: [PATCH 227/316] fix: major type-based issues with PEP Signed-off-by: DefinetlyNotAI --- core/bluetooth/bluetooth_addresses.py | 20 ++- core/filesystem/sensitive_file_inventory.py | 32 +++-- core/network/network_interfaces.py | 13 +- core/packet/packet_capture.py | 139 +++++++++++++++----- core/process/memory_map.py | 16 ++- core/system/bios_info.py | 2 +- logicytics/api.py | 13 +- logicytics/configuration.py | 7 +- logicytics/contracts.py | 21 ++- logicytics/interaction.py | 34 ++++- 10 files changed, 234 insertions(+), 63 deletions(-) diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index b001d5fc..377a0162 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -17,10 +17,24 @@ def _is_access_denied(detail: str) -> bool: return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) -def _format_address(instance_id: object) -> str | None: +def _format_address(instance_id: str | None) -> str | None: """Extract one conventional colon-separated address from a PnP instance identifier.""" - match = re.search(r"(? CollectorMetadata: def validate(self, context: CollectorContext) -> ValidationResult: """Validate bounded search settings before walking the requested root.""" if context.is_cancelled: - return ValidationResult(False, reasons=("run cancellation was requested",)) - try: - if int(context.settings.get("max_directories", 5_000)) < 1 or int( - context.settings.get("max_matches", 500)) < 1: - return ValidationResult(False, reasons=("max_directories and max_matches must be positive",)) - except (TypeError, ValueError): - return ValidationResult(False, reasons=("max_directories and max_matches must be integers",)) + return ValidationResult( + False, + reasons=("run cancellation was requested",), + ) + + max_directories = context.setting_int("max_directories", 5_000) + max_matches = context.setting_int("max_matches", 500) + + if max_directories < 1 or max_matches < 1: + return ValidationResult( + False, + reasons=("max_directories and max_matches must be positive",), + ) + return ValidationResult(True) def collect(self, context: CollectorContext) -> CollectorResult: """Find matches and copy them through the scheduler-owned collector worker.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before sensitive-file inventory") - root = Path(str(context.settings.get("root", filesystem_adapter.system_drive_root()))) - max_directories = int(context.settings.get("max_directories", 5_000)) - max_matches = int(context.settings.get("max_matches", 500)) + root = Path( + context.setting_str( + "root", + str(filesystem_adapter.system_drive_root()), + ) + ) + max_directories = context.setting_int("max_directories", 5_000) + max_matches = context.setting_int("max_matches", 500) matches: list[Path] = [] scanned_directories = 0 context.report_progress("sensitive_file_inventory_started", root=str(root)) diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index e32d47ba..21a30725 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -20,19 +20,26 @@ def _is_access_denied(detail: str) -> bool: def _enrich_ipv4_networks(records: list[dict[str, object]]) -> list[dict[str, object]]: """Add deterministic IPv4 netmask and broadcast fields from each prefix length.""" enriched: list[dict[str, object]] = [] + for record in records: item = dict(record) address = item.get("IPAddress") prefix = item.get("PrefixLength") + if isinstance(address, str) and isinstance(prefix, int): try: - network = ipaddress.IPv4Network(f"{address}/{prefix}", strict=False) + network = ipaddress.IPv4Network( + f"{address}/{prefix}", + strict=False, + ) except ValueError: pass else: - item["Netmask"] = str(network.netmask) - item["BroadcastAddress"] = str(network.broadcast_address) + item["Netmask"] = network.netmask.compressed + item["BroadcastAddress"] = network.broadcast_address.compressed + enriched.append(item) + return enriched diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index 889fb563..239fd99c 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -63,9 +63,9 @@ def validate(self, context: CollectorContext) -> ValidationResult: if context.is_cancelled: return ValidationResult(False, reasons=("run cancellation was requested",)) try: - count = int(context.settings.get("packet_count", 100)) - timeout = float(context.settings.get("timeout_seconds", 10)) - retry_window = float(context.settings.get("retry_window_seconds", 0)) + count = context.setting_int("packet_count", 100) + timeout = context.setting_float("timeout_seconds", 10.0) + retry_window = context.setting_float("retry_window_seconds", 0.0) except (TypeError, ValueError): return ValidationResult(False, reasons=( "packet_count, timeout_seconds, and retry_window_seconds must be numeric",)) @@ -76,60 +76,123 @@ def validate(self, context: CollectorContext) -> ValidationResult: ) return ValidationResult(True) + @staticmethod + def _close_capture(capture_socket: socket.socket) -> None: + try: + capture_socket.ioctl( + socket.SIO_RCVALL, + socket.RCVALL_OFF, + ) + except OSError: + pass + capture_socket.close() + def collect(self, context: CollectorContext) -> CollectorResult: """Capture bounded metadata and save it as CSV without retaining payload bytes.""" if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before packet capture") - count = int(context.settings.get("packet_count", 100)) - timeout = float(context.settings.get("timeout_seconds", 10)) - retry_window = float(context.settings.get("retry_window_seconds", 0)) - interface = str(context.settings.get("interface", socket.gethostbyname(socket.gethostname()))) + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before packet capture", + ) + + count = context.setting_int("packet_count", 100) + timeout = context.setting_float("timeout_seconds", 10.0) + retry_window = context.setting_float("retry_window_seconds", 0.0) + interface = context.setting_str( + "interface", + socket.gethostbyname(socket.gethostname()), + ) + context.report_progress( "packet_capture_started", interface=interface, packet_count=count, retry_window_seconds=retry_window, ) + output = context.workspace / "packet_capture.csv" capture: socket.socket | None = None observation_count = 0 early_result: CollectorResult | None = None + with output.open("w", newline="", encoding="utf-8") as stream: - writer = csv.DictWriter(stream, fieldnames=("source_ip", "destination_ip", "protocol", "source_port", - "destination_port", "packet_bytes")) + writer = csv.DictWriter( + stream, + fieldnames=( + "source_ip", + "destination_ip", + "protocol", + "source_port", + "destination_port", + "packet_bytes", + ), + ) writer.writeheader() + try: - capture = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP) - capture.bind((interface, 0)) - capture.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) - capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_ON) + active_capture = socket.socket( + socket.AF_INET, + socket.SOCK_RAW, + socket.IPPROTO_IP, + ) + capture = active_capture + + active_capture.bind((interface, 0)) + active_capture.setsockopt( + socket.IPPROTO_IP, + socket.IP_HDRINCL, + 1, + ) + active_capture.ioctl( + socket.SIO_RCVALL, + socket.RCVALL_ON, + ) + deadline = time.monotonic() + timeout retry_deadline = time.monotonic() + retry_window - while observation_count < count and time.monotonic() < deadline: + + while ( + observation_count < count + and time.monotonic() < deadline + ): if context.is_cancelled: early_result = CollectorResult( - CollectorStatus.CANCELLED, "cancelled during packet capture" + CollectorStatus.CANCELLED, + "cancelled during packet capture", ) break - ready, _, _ = select.select([capture], [], [], min(1.0, deadline - time.monotonic())) + + remaining = max(0.0, deadline - time.monotonic()) + ready, _, _ = select.select( + [active_capture], + [], + [], + min(1.0, remaining), + ) + if not ready: continue + try: - payload = capture.recv(65_535) + payload = active_capture.recv(65_535) except OSError: if time.monotonic() < retry_deadline: time.sleep(0.1) continue raise + row = _packet_row(payload) if row is not None: writer.writerow(row) observation_count += 1 + except PermissionError as error: early_result = CollectorResult( - CollectorStatus.SKIPPED, "raw packet capture requires an elevated account", + CollectorStatus.SKIPPED, + "raw packet capture requires an elevated account", errors=(str(error),), ) + except OSError as error: if error.winerror in {5, 10013}: early_result = CollectorResult( @@ -139,25 +202,41 @@ def collect(self, context: CollectorContext) -> CollectorResult: ) else: early_result = CollectorResult( - CollectorStatus.FAILED, "raw packet capture failed", errors=(str(error),) + CollectorStatus.FAILED, + "raw packet capture failed", + errors=(str(error),), ) + finally: if capture is not None: - try: - capture.ioctl(socket.SIO_RCVALL, socket.RCVALL_OFF) - except OSError: - pass - capture.close() + self._close_capture(capture) + if early_result is not None: output.unlink(missing_ok=True) return early_result + if context.is_cancelled: output.unlink(missing_ok=True) - return CollectorResult(CollectorStatus.CANCELLED, "cancelled after packet capture") - artifact = context.artifacts.register_file(output, media_type="text/csv") - context.report_progress("packet_capture_finished", observation_count=observation_count, - bytes_written=artifact.size_bytes) - return CollectorResult.succeeded("packet metadata captured", (artifact,)) + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled after packet capture", + ) + + artifact = context.artifacts.register_file( + output, + media_type="text/csv", + ) + + context.report_progress( + "packet_capture_finished", + observation_count=observation_count, + bytes_written=artifact.size_bytes, + ) + + return CollectorResult.succeeded( + "packet metadata captured", + (artifact,), + ) def cleanup(self, context: CollectorContext) -> None: """Raw socket cleanup happens in collect's finally block.""" diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 1ffa3ead..1e2c82ef 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -66,10 +66,18 @@ def collect(self, context: CollectorContext) -> CollectorResult: """Query readable local memory regions and write metadata-only JSON evidence.""" if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory-map collection") - maximum = int(context.settings.get("max_regions", 5_000)) - output_limit = int(context.settings.get("output_limit_bytes", 64 * 1024 * 1024)) - safety_margin = int(context.settings.get("disk_safety_margin_bytes", 100 * 1024 * 1024)) - configured_directory = Path(str(context.settings.get("dump_directory", "memory_maps"))) + maximum = context.setting_int("max_regions", 5_000) + output_limit = context.setting_int( + "output_limit_bytes", + 64 * 1024 * 1024, + ) + safety_margin = context.setting_int( + "disk_safety_margin_bytes", + 100 * 1024 * 1024, + ) + configured_directory = Path( + context.setting_str("dump_directory", "memory_maps") + ) output_directory = (context.workspace / configured_directory).resolve() try: output_directory.relative_to(context.workspace.resolve()) diff --git a/core/system/bios_info.py b/core/system/bios_info.py index efdd1cc2..6405ba63 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -24,7 +24,7 @@ def _is_access_denied(detail: str) -> bool: return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) -def render_bios_table(bios: dict[str, object]) -> str: +def render_bios_table(bios: dict[str, str | None]) -> str: """Render trusted structured BIOS fields into a portable evidence table.""" rows = "\n".join( f" {html.escape(label)}{html.escape(str(bios.get(key) or 'unavailable'))}" diff --git a/logicytics/api.py b/logicytics/api.py index 4b987b6e..43565566 100644 --- a/logicytics/api.py +++ b/logicytics/api.py @@ -271,10 +271,15 @@ def query_run( or isinstance(manifest_schema_version, bool) or manifest_schema_version != MANIFEST_SCHEMA_VERSION ): - raise PlanError( - f"unsupported run manifest schema_version {manifest_schema_version!r}; " - f"expected {MANIFEST_SCHEMA_VERSION}" - ) + if manifest_schema_version is None: + raise PlanError("run manifest is missing schema_version") + + if manifest_schema_version != MANIFEST_SCHEMA_VERSION: + raise PlanError( + f"unsupported run manifest schema_version {manifest_schema_version!r}; " + f"expected {MANIFEST_SCHEMA_VERSION}" + ) + _manifest_timestamp(payload.get("requested_at"), "requested_at") if not isinstance(payload.get("status"), str): raise PlanError("run manifest contains an unsupported run status") diff --git a/logicytics/configuration.py b/logicytics/configuration.py index 34ccea3d..d82a0701 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -49,6 +49,9 @@ "max_workers": "maximum_workers", }) +type CollectorSettingValue = str | int | float +type CollectorSettings = Mapping[str, CollectorSettingValue] + @dataclass(frozen=True, slots=True) class CollectorSettingRule: @@ -339,10 +342,10 @@ class AppConfig: interaction: InteractionSettings = field(default_factory=InteractionSettings) maintenance: MaintenanceSettings = field(default_factory=MaintenanceSettings) logging: LoggingSettings = field(default_factory=LoggingSettings) - collector_settings: Mapping[str, Mapping[str, Any]] = field(default_factory=dict) + collector_settings: Mapping[str, CollectorSettings] = field(default_factory=dict) migrated_from_schema: int | None = None - def settings_for(self, collector_id: str) -> Mapping[str, Any]: + def settings_for(self, collector_id: str) -> CollectorSettings: """Return the isolated settings declared for one collector.""" return self.collector_settings.get(collector_id, {}) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 6ddff218..1230f3db 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -561,7 +561,7 @@ def __init__( temporary_directory: Path, artifacts: "ArtifactWriter", logger: EventLogger, - settings: Mapping[str, Any], + settings: Mapping[str, object], cancellation_file: Path, ) -> None: self.run_id = run_id @@ -578,6 +578,25 @@ def is_cancelled(self) -> bool: """Whether the supervisor has requested cancellation.""" return self._cancellation_file.exists() + def setting_int(self, name: str, default: int) -> int: + """Return one integer collector setting or its default.""" + value = self.settings.get(name, default) + if isinstance(value, int) and not isinstance(value, bool): + return value + return default + + def setting_float(self, name: str, default: float) -> float: + """Return one numeric collector setting or its default.""" + value = self.settings.get(name, default) + if isinstance(value, (int, float)) and not isinstance(value, bool): + return float(value) + return default + + def setting_str(self, name: str, default: str) -> str: + """Return one textual collector setting or its default.""" + value = self.settings.get(name, default) + return value if isinstance(value, str) else default + def report_progress(self, event: str, **metrics: int | float | str) -> None: """Write a structured progress event local to this collector workspace.""" self.logger.event("info", event, **metrics) diff --git a/logicytics/interaction.py b/logicytics/interaction.py index 6ea0bc5f..69a8f629 100644 --- a/logicytics/interaction.py +++ b/logicytics/interaction.py @@ -120,13 +120,37 @@ def record_match(path: Path, match: FlagMatch) -> None: def usage_statistics(history: Iterable[Mapping[str, object]]) -> dict[str, object]: """Aggregate total, accuracy, common values, and per-flag frequencies.""" records = list(history) - accuracies = [float(item["accuracy"]) for item in records if isinstance(item.get("accuracy"), (int, float))] - flags = Counter(str(item["matched_flag"]) for item in records if isinstance(item.get("matched_flag"), str)) - devices = Counter(str(item["device_name"]) for item in records if isinstance(item.get("device_name"), str)) - inputs = Counter(str(item["input"]) for item in records if isinstance(item.get("input"), str)) + + accuracies: list[float] = [] + flags: Counter[str] = Counter() + devices: Counter[str] = Counter() + inputs: Counter[str] = Counter() + + for item in records: + accuracy = item.get("accuracy") + matched_flag = item.get("matched_flag") + device_name = item.get("device_name") + input_value = item.get("input") + + if isinstance(accuracy, (int, float)): + accuracies.append(float(accuracy)) + + if isinstance(matched_flag, str): + flags[matched_flag] += 1 + + if isinstance(device_name, str): + devices[device_name] += 1 + + if isinstance(input_value, str): + inputs[input_value] += 1 + return { "total_interactions": len(records), - "average_accuracy": round(sum(accuracies) / len(accuracies), 6) if accuracies else 0.0, + "average_accuracy": ( + round(sum(accuracies) / len(accuracies), 6) + if accuracies + else 0.0 + ), "common_device": devices.most_common(1)[0][0] if devices else None, "common_input": inputs.most_common(1)[0][0] if inputs else None, "per_flag_frequency": dict(sorted(flags.items())), From 25a16f42340aa8eb196ca25794c07989098ab520 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 00:09:09 +0300 Subject: [PATCH 228/316] fix: major type-based issues with PEP Signed-off-by: DefinetlyNotAI --- logicytics/cli.py | 917 +++++++++++++------------- tests/test_core_functionality.py | 1043 ++++++++++++++++++++++++------ 2 files changed, 1288 insertions(+), 672 deletions(-) diff --git a/logicytics/cli.py b/logicytics/cli.py index 2d3e76b8..9eba31f3 100644 --- a/logicytics/cli.py +++ b/logicytics/cli.py @@ -44,534 +44,536 @@ from logicytics.sysinternals import ensure_sysinternals -def _project_root() -> Path: - return Path(__file__).resolve().parent.parent - - -def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: - legacy_flags = { - flag: getattr(arguments, flag, False) - for flag in LEGACY_MODE_ALIASES - } - - mode = resolve_execution_mode(getattr(arguments, "mode", None), legacy_flags) - explicit_profile = getattr(arguments, "profile", None) +class CLI: + @staticmethod + def project_root() -> Path: + return Path(__file__).resolve().parent.parent + + @staticmethod + def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: + legacy_flags = { + flag: getattr(arguments, flag, False) + for flag in LEGACY_MODE_ALIASES + } - if mode is not None and explicit_profile is not None: - raise ValueError("--profile cannot be combined with a named collection mode") + mode = resolve_execution_mode(getattr(arguments, "mode", None), legacy_flags) + explicit_profile = getattr(arguments, "profile", None) - profile = mode.profile if mode is not None else explicit_profile or "standard" + if mode is not None and explicit_profile is not None: + raise ValueError("--profile cannot be combined with a named collection mode") - explicit_sequential = getattr(arguments, "sequential", False) - explicit_parallel = getattr(arguments, "parallel", False) + profile = mode.profile if mode is not None else explicit_profile or "standard" - if mode is not None: - if explicit_sequential and mode.strategy is ExecutionStrategy.PARALLEL: - if legacy_flags["threaded"]: - raise ValueError( - "sequential execution conflicts with legacy --threaded mode" - ) - raise ValueError( - f"{mode.name} mode requires configured parallel execution" - ) + explicit_sequential = getattr(arguments, "sequential", False) + explicit_parallel = getattr(arguments, "parallel", False) - if explicit_parallel and mode.strategy is ExecutionStrategy.SEQUENTIAL: - if legacy_flags["performance_check"] or legacy_flags["default_mode"]: + if mode is not None: + if explicit_sequential and mode.strategy is ExecutionStrategy.PARALLEL: + if legacy_flags["threaded"]: + raise ValueError( + "sequential execution conflicts with legacy --threaded mode" + ) raise ValueError( - "parallel execution conflicts with sequential performance/default mode" + f"{mode.name} mode requires configured parallel execution" ) - raise ValueError(f"{mode.name} mode requires sequential execution") - sequential = explicit_sequential or ( - mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL - ) - parallel = explicit_parallel or ( - mode is not None and mode.strategy is ExecutionStrategy.PARALLEL - ) - - performance_check = mode.performance_check if mode is not None else False - - enable_mods = getattr(arguments, "mods", False) or ( - mode.enable_mods if mode is not None else False - ) - - non_python_only = mode.non_python_only if mode is not None else False + if explicit_parallel and mode.strategy is ExecutionStrategy.SEQUENTIAL: + if legacy_flags["performance_check"] or legacy_flags["default_mode"]: + raise ValueError( + "parallel execution conflicts with sequential performance/default mode" + ) + raise ValueError(f"{mode.name} mode requires sequential execution") - if sequential and arguments.workers is not None and arguments.workers != 1: - raise ValueError("sequential execution requires --workers=1") + sequential = explicit_sequential or ( + mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL + ) + parallel = explicit_parallel or ( + mode is not None and mode.strategy is ExecutionStrategy.PARALLEL + ) - worker_count = 1 if sequential else arguments.workers or default_workers + performance_check = mode.performance_check if mode is not None else False - if parallel and worker_count < 2: - raise ValueError( - "parallel execution requires at least two configured workers" + enable_mods = getattr(arguments, "mods", False) or ( + mode.enable_mods if mode is not None else False ) - parent_run_id: str | None = None + non_python_only = mode.non_python_only if mode is not None else False - rerun_value = getattr(arguments, "rerun_from", None) + if sequential and arguments.workers is not None and arguments.workers != 1: + raise ValueError("sequential execution requires --workers=1") - if rerun_value is not None: - if not isinstance(rerun_value, (str, os.PathLike)): - raise TypeError( - f"rerun_from must be a path-like value, got {type(rerun_value).__name__}" - ) + worker_count = 1 if sequential else arguments.workers or default_workers - rerun_path = Path(rerun_value) - - if not arguments.include: + if parallel and worker_count < 2: raise ValueError( - "--rerun-from requires at least one explicit --include collector ID" + "parallel execution requires at least two configured workers" ) - manifest_path = ( - rerun_path / "manifest.json" - if rerun_path.is_dir() - else rerun_path - ) + parent_run_id: str | None = None - try: - previous = json.loads( - manifest_path.read_text(encoding="utf-8") - ) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - raise ValueError( - f"original run manifest cannot be loaded: {error}" - ) from error + rerun_value = getattr(arguments, "rerun_from", None) - if not isinstance(previous, dict) or not isinstance( - previous.get("run_id"), - str, - ): - raise ValueError( - "original run manifest must contain a valid run_id" - ) + if rerun_value is not None: + if not isinstance(rerun_value, (str, os.PathLike)): + raise TypeError( + f"rerun_from must be a path-like value, got {type(rerun_value).__name__}" + ) - manifest_schema_version = previous.get("manifest_schema_version") + rerun_path = Path(rerun_value) - if ( - not isinstance(manifest_schema_version, int) - or isinstance(manifest_schema_version, bool) - or manifest_schema_version != MANIFEST_SCHEMA_VERSION - ): - schema_version_display = ( - "None" - if manifest_schema_version is None - else repr(manifest_schema_version) - ) + if not arguments.include: + raise ValueError( + "--rerun-from requires at least one explicit --include collector ID" + ) - raise ValueError( - f"original run manifest uses unsupported schema_version " - f"{schema_version_display}; expected {MANIFEST_SCHEMA_VERSION}" + manifest_path = ( + rerun_path / "manifest.json" + if rerun_path.is_dir() + else rerun_path ) - if previous.get("status") not in { - "succeeded", - "partial", - "failed", - "cancelled", - }: - raise ValueError( - "original run manifest must describe a finalized run" - ) + try: + previous = json.loads( + manifest_path.read_text(encoding="utf-8") + ) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ValueError( + f"original run manifest cannot be loaded: {error}" + ) from error - resolved = previous.get("resolved_plan") + if not isinstance(previous, dict) or not isinstance( + previous.get("run_id"), + str, + ): + raise ValueError( + "original run manifest must contain a valid run_id" + ) - if not isinstance(resolved, list) or not all( - isinstance(item, str) - for item in resolved - ): - raise ValueError( - "original run manifest must contain a valid resolved_plan" - ) + manifest_schema_version = previous.get("manifest_schema_version") - unknown = sorted( - set(arguments.include) - set(resolved) - ) + if ( + not isinstance(manifest_schema_version, int) + or isinstance(manifest_schema_version, bool) + or manifest_schema_version != MANIFEST_SCHEMA_VERSION + ): + schema_version_display = ( + "None" + if manifest_schema_version is None + else repr(manifest_schema_version) + ) - if unknown: - raise ValueError( - "rerun collectors were not present in the original run: " - f"{', '.join(unknown)}" - ) + raise ValueError( + f"original run manifest uses unsupported schema_version " + f"{schema_version_display}; expected {MANIFEST_SCHEMA_VERSION}" + ) - parent_run_id = previous["run_id"] + if previous.get("status") not in { + "succeeded", + "partial", + "failed", + "cancelled", + }: + raise ValueError( + "original run manifest must describe a finalized run" + ) - selection_only = getattr(arguments, "command", None) == "collector" + resolved = previous.get("resolved_plan") - if selection_only and ( - arguments.include - or arguments.exclude - or arguments.profile is not None - or arguments.plugins - or arguments.mods - ): - raise ValueError( - "collector execution cannot combine its ID with profile or selection flags" - ) + if not isinstance(resolved, list) or not all( + isinstance(item, str) + for item in resolved + ): + raise ValueError( + "original run manifest must contain a valid resolved_plan" + ) - includes = ( - (arguments.collector_id,) - if selection_only - else tuple(arguments.include) - ) + unknown = sorted( + set(arguments.include) - set(resolved) + ) - return RunRequest( - profile=profile, - include=includes, - exclude=tuple(arguments.exclude), - selection_only=selection_only, - enable_plugins=arguments.plugins, - enable_mods=enable_mods, - non_python_only=non_python_only, - max_workers=worker_count, - acknowledge_authorization=getattr( - arguments, - "acknowledge_authorization", - False, - ), - approved_capabilities=tuple( - Capability(value) - for value in arguments.allow_capability - ), - performance_check=performance_check, - rerun_from=parent_run_id, - output_policy=( - OutputPolicy.MANIFEST_ONLY - if getattr(arguments, "no_package", False) - else OutputPolicy.PACKAGE - ), - post_run_action=( - PostRunAction.REBOOT - if getattr(arguments, "reboot", False) - else PostRunAction.SHUTDOWN - if getattr(arguments, "shutdown", False) - else PostRunAction.NONE - ), - ) + if unknown: + raise ValueError( + "rerun collectors were not present in the original run: " + f"{', '.join(unknown)}" + ) + parent_run_id = previous["run_id"] -def parser() -> argparse.ArgumentParser: - parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") - parser.add_argument( - "--config", - type=Path, - help="Path to a v4 JSON or supported legacy INI configuration file", - ) - parser.add_argument("--usage", action="store_true", - help="Show local interaction statistics and create a usage graph.") - parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") - parser.add_argument("--match", metavar="TEXT", - help="Suggest the closest documented action for natural-language input.") - subcommands = parser.add_subparsers(dest="command") - for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): - subparser = subcommands.add_parser(command, help=f"Run the {command} action.") - subparser.add_argument( - "--profile", - default=None, - choices=tuple(BUILTIN_PROFILES), - help="Named built-in collector membership and access policy.", - ) - subparser.add_argument( - "--include", action="append", default=[], metavar="COLLECTOR_ID", - help="Include a collector by exact dotted ID; repeat for multiple collectors.", - ) - subparser.add_argument( - "--exclude", action="append", default=[], metavar="COLLECTOR_ID", - help="Exclude a collector by exact dotted ID; repeat for multiple collectors.", - ) - subparser.add_argument( - "--plugins", action="store_true", - help="Enable all valid opt-in plugin collectors for the selected profile.", - ) - subparser.add_argument( - "--mods", action="store_true", - help="Enable valid sidecar-declared scripts from the MODS directory.", + selection_only = getattr(arguments, "command", None) == "collector" + + if selection_only and ( + arguments.include + or arguments.exclude + or arguments.profile is not None + or arguments.plugins + or arguments.mods + ): + raise ValueError( + "collector execution cannot combine its ID with profile or selection flags" + ) + + includes = ( + (arguments.collector_id,) + if selection_only + else tuple(arguments.include) ) - subparser.add_argument( - "--workers", type=int, metavar="COUNT", - help="Bound concurrent isolated workers to this positive count.", + + return RunRequest( + profile=profile, + include=includes, + exclude=tuple(arguments.exclude), + selection_only=selection_only, + enable_plugins=arguments.plugins, + enable_mods=enable_mods, + non_python_only=non_python_only, + max_workers=worker_count, + acknowledge_authorization=getattr( + arguments, + "acknowledge_authorization", + False, + ), + approved_capabilities=tuple( + Capability(value) + for value in arguments.allow_capability + ), + performance_check=performance_check, + rerun_from=parent_run_id, + output_policy=( + OutputPolicy.MANIFEST_ONLY + if getattr(arguments, "no_package", False) + else OutputPolicy.PACKAGE + ), + post_run_action=( + PostRunAction.REBOOT + if getattr(arguments, "reboot", False) + else PostRunAction.SHUTDOWN + if getattr(arguments, "shutdown", False) + else PostRunAction.NONE + ), ) - subparser.add_argument( - "--allow-capability", - action="append", - default=[], - choices=[capability.value for capability in Capability], - help="Approve an access capability requested by the selected collectors.", + + @staticmethod + def parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") + parser.add_argument( + "--config", + type=Path, + help="Path to a v4 JSON or supported legacy INI configuration file", ) - if command == "run": + parser.add_argument("--usage", action="store_true", + help="Show local interaction statistics and create a usage graph.") + parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") + parser.add_argument("--match", metavar="TEXT", + help="Suggest the closest documented action for natural-language input.") + subcommands = parser.add_subparsers(dest="command") + for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): + subparser = subcommands.add_parser(command, help=f"Run the {command} action.") subparser.add_argument( - "--rerun-from", - type=Path, - help="Rerun explicitly included collector IDs from a finalized run manifest or directory.", - ) - execution = subparser.add_mutually_exclusive_group() - execution.add_argument( - "--sequential", - action="store_true", - help="Explicitly run isolated collectors one at a time for deterministic debugging.", - ) - execution.add_argument( - "--parallel", - action="store_true", - help="Explicitly run isolated collectors with the configured bounded worker limit.", - ) - mode = subparser.add_mutually_exclusive_group() - mode.add_argument( - "--mode", - choices=tuple(EXECUTION_MODES), - help="Select one user-facing typed execution mode.", - ) - mode.add_argument("--default", dest="default_mode", action="store_true", - help="Run the standard built-in profile.") - mode.add_argument("--threaded", action="store_true", - help="Run the standard built-in profile with configured parallel workers.") - mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") - mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") - mode.add_argument("--modded", action="store_true", - help="Run the standard profile plus all valid MODS scripts.") - mode.add_argument("--nopy", action="store_true", - help="Run only non-Python PowerShell, batch, and executable MODS scripts.") - mode.add_argument( - "--performance-check", - action="store_true", - help="Run serially and save per-collector duration measurements.", + "--profile", + default=None, + choices=tuple(BUILTIN_PROFILES), + help="Named built-in collector membership and access policy.", ) subparser.add_argument( - "--no-package", - action="store_true", - help="Finalize the run manifest without creating a ZIP package.", - ) - power_action = subparser.add_mutually_exclusive_group() - power_action.add_argument( - "--reboot", - action="store_true", - help="Schedule a reboot only after the run is packaged successfully.", - ) - power_action.add_argument( - "--shutdown", - action="store_true", - help="Schedule a shutdown only after the run is packaged successfully.", + "--include", action="append", default=[], metavar="COLLECTOR_ID", + help="Include a collector by exact dotted ID; repeat for multiple collectors.", ) subparser.add_argument( - "--acknowledge-authorization", - action="store_true", - help="Confirm you are authorized to collect the selected evidence.", + "--exclude", action="append", default=[], metavar="COLLECTOR_ID", + help="Exclude a collector by exact dotted ID; repeat for multiple collectors.", ) subparser.add_argument( - "--interactive", - action="store_true", - help="Pause at the final status so an interactive command window remains visible.", + "--plugins", action="store_true", + help="Enable all valid opt-in plugin collectors for the selected profile.", ) - if command == "collector": - subparser.add_argument("collector_id", help="Exact dotted ID of the collector to run independently.") subparser.add_argument( - "--acknowledge-authorization", - action="store_true", - help="Confirm you are authorized to collect this collector's evidence.", + "--mods", action="store_true", + help="Enable valid sidecar-declared scripts from the MODS directory.", ) subparser.add_argument( - "--no-package", - action="store_true", - help="Finalize the direct collector manifest without creating a ZIP package.", + "--workers", type=int, metavar="COUNT", + help="Bound concurrent isolated workers to this positive count.", ) subparser.add_argument( - "--interactive", - action="store_true", - help="Pause at the final status so an interactive command window remains visible.", - ) - if command == "update": - subparser.add_argument("--apply", action="store_true", - help="Explicitly run git pull after repository checks.") - subparser.add_argument( - "--launch-action", - choices=("preflight", "debug", "dev"), - help="Select a safe maintenance action to launch after a successful update check.", - ) - subparser.add_argument( - "--new-window", - action="store_true", - help="Launch --launch-action in a visible, separate Windows command window.", - ) - subparser.add_argument( - "--performance-check", - action="store_true", - help="Run collectors sequentially and write a per-collector duration report.", - ) - if command == "dev": - subparser.add_argument( - "--write-manifest", - action="store_true", - help=( - "Write the reviewed local integrity manifest; requires --next-version " - "unless interactive." - ), - ) - subparser.add_argument( - "--next-version", - help=( - "Semantic version to validate and record in a newly written " - "integrity manifest." - ), - ) - subparser.add_argument( - "--interactive", - action="store_true", - help=( - "Show contribution checks and prompt before changing the local " - "integrity manifest." - ), - ) - return parser - - -def _write_json(path: Path, payload: object) -> Path: - """Atomically write a human-readable JSON diagnostic artifact.""" - path.parent.mkdir(parents=True, exist_ok=True) - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(path) - return path - - -def launch_action_window(root: Path, action: str) -> int: - """Launch one allowlisted maintenance action in a separate visible Windows console.""" - if sys.platform != "win32": - raise OSError("new command windows are supported only on Windows") - if action not in {"preflight", "debug", "dev"}: - raise ValueError(f"unsupported new-window action: {action}") - command = [sys.executable, "-m", "logicytics", action] - process = process_adapter.popen( - command, - cwd=root, - shell=False, - creationflags=process_adapter.create_new_console, - close_fds=True, - ) - return process.pid - - -def _status_marker(label: str, count: int) -> str: - """Return a colored interactive repository-status marker when a terminal supports it.""" - color = "\033[32m" if count == 0 else "\033[33m" - reset = "\033[0m" - return f"{color}{label}: {count}{reset}" if sys.stdout.isatty() else f"{label}: {count}" - - -def _run_developer_action( - root: Path, - configuration: AppConfig, - arguments: argparse.Namespace, -) -> int: - """Run read-only contribution checks and an explicitly confirmed manifest update.""" - settings = configuration.maintenance - existing = load_local_manifest(root, settings) - - comparison = ( - compare_files(root, settings, existing) - if existing - else { - "missing": [], - "modified": [], - "extra": [], - "unchanged": [], - } - ) - - checks = developer_checks(root, settings) - next_version = arguments.next_version - write_requested = arguments.write_manifest - - if arguments.interactive: - print("Contribution and repository organization checks") + "--allow-capability", + action="append", + default=[], + choices=[capability.value for capability in Capability], + help="Approve an access capability requested by the selected collectors.", + ) + if command == "run": + subparser.add_argument( + "--rerun-from", + type=Path, + help="Rerun explicitly included collector IDs from a finalized run manifest or directory.", + ) + execution = subparser.add_mutually_exclusive_group() + execution.add_argument( + "--sequential", + action="store_true", + help="Explicitly run isolated collectors one at a time for deterministic debugging.", + ) + execution.add_argument( + "--parallel", + action="store_true", + help="Explicitly run isolated collectors with the configured bounded worker limit.", + ) + mode = subparser.add_mutually_exclusive_group() + mode.add_argument( + "--mode", + choices=tuple(EXECUTION_MODES), + help="Select one user-facing typed execution mode.", + ) + mode.add_argument("--default", dest="default_mode", action="store_true", + help="Run the standard built-in profile.") + mode.add_argument("--threaded", action="store_true", + help="Run the standard built-in profile with configured parallel workers.") + mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") + mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") + mode.add_argument("--modded", action="store_true", + help="Run the standard profile plus all valid MODS scripts.") + mode.add_argument("--nopy", action="store_true", + help="Run only non-Python PowerShell, batch, and executable MODS scripts.") + mode.add_argument( + "--performance-check", + action="store_true", + help="Run serially and save per-collector duration measurements.", + ) + subparser.add_argument( + "--no-package", + action="store_true", + help="Finalize the run manifest without creating a ZIP package.", + ) + power_action = subparser.add_mutually_exclusive_group() + power_action.add_argument( + "--reboot", + action="store_true", + help="Schedule a reboot only after the run is packaged successfully.", + ) + power_action.add_argument( + "--shutdown", + action="store_true", + help="Schedule a shutdown only after the run is packaged successfully.", + ) + subparser.add_argument( + "--acknowledge-authorization", + action="store_true", + help="Confirm you are authorized to collect the selected evidence.", + ) + subparser.add_argument( + "--interactive", + action="store_true", + help="Pause at the final status so an interactive command window remains visible.", + ) + if command == "collector": + subparser.add_argument("collector_id", help="Exact dotted ID of the collector to run independently.") + subparser.add_argument( + "--acknowledge-authorization", + action="store_true", + help="Confirm you are authorized to collect this collector's evidence.", + ) + subparser.add_argument( + "--no-package", + action="store_true", + help="Finalize the direct collector manifest without creating a ZIP package.", + ) + subparser.add_argument( + "--interactive", + action="store_true", + help="Pause at the final status so an interactive command window remains visible.", + ) + if command == "update": + subparser.add_argument("--apply", action="store_true", + help="Explicitly run git pull after repository checks.") + subparser.add_argument( + "--launch-action", + choices=("preflight", "debug", "dev"), + help="Select a safe maintenance action to launch after a successful update check.", + ) + subparser.add_argument( + "--new-window", + action="store_true", + help="Launch --launch-action in a visible, separate Windows command window.", + ) + subparser.add_argument( + "--performance-check", + action="store_true", + help="Run collectors sequentially and write a per-collector duration report.", + ) + if command == "dev": + subparser.add_argument( + "--write-manifest", + action="store_true", + help=( + "Write the reviewed local integrity manifest; requires --next-version " + "unless interactive." + ), + ) + subparser.add_argument( + "--next-version", + help=( + "Semantic version to validate and record in a newly written " + "integrity manifest." + ), + ) + subparser.add_argument( + "--interactive", + action="store_true", + help=( + "Show contribution checks and prompt before changing the local " + "integrity manifest." + ), + ) + return parser + + @staticmethod + def write_json(path: Path, payload: object) -> Path: + """Atomically write a human-readable JSON diagnostic artifact.""" + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") + temporary.replace(path) + return path + + @staticmethod + def launch_action_window(root: Path, action: str) -> int: + """Launch one allowlisted maintenance action in a separate visible Windows console.""" + if sys.platform != "win32": + raise OSError("new command windows are supported only on Windows") + if action not in {"preflight", "debug", "dev"}: + raise ValueError(f"unsupported new-window action: {action}") + command = [sys.executable, "-m", "logicytics", action] + process = process_adapter.popen( + command, + cwd=root, + shell=False, + creationflags=process_adapter.create_new_console, + close_fds=True, + ) + return process.pid + + @staticmethod + def _status_marker(label: str, count: int) -> str: + """Return a colored interactive repository-status marker when a terminal supports it.""" + color = "\033[32m" if count == 0 else "\033[33m" + reset = "\033[0m" + return f"{color}{label}: {count}{reset}" if sys.stdout.isatty() else f"{label}: {count}" + + def run_developer_action( + self, + root: Path, + configuration: AppConfig, + arguments: argparse.Namespace, + ) -> int: + """Run read-only contribution checks and an explicitly confirmed manifest update.""" + settings = configuration.maintenance + existing = load_local_manifest(root, settings) + + comparison = ( + compare_files(root, settings, existing) + if existing + else { + "missing": [], + "modified": [], + "extra": [], + "unchanged": [], + } + ) - for name in ("missing", "modified", "extra", "unchanged"): - print(_status_marker(name.title(), len(comparison[name]))) + checks = developer_checks(root, settings) + next_version = arguments.next_version + write_requested = arguments.write_manifest - organization_checks: tuple[ - Literal[ + if arguments.interactive: + print("Contribution and repository organization checks") + + for name in ("missing", "modified", "extra", "unchanged"): + print(self._status_marker(name.title(), len(comparison[name]))) + + organization_checks: tuple[ + Literal[ + "naming_violations", + "misplaced_python", + "missing_module_docstrings", + "crowded_modules", + ], + ..., + ] = ( "naming_violations", "misplaced_python", "missing_module_docstrings", "crowded_modules", - ], - ..., - ] = ( - "naming_violations", - "misplaced_python", - "missing_module_docstrings", - "crowded_modules", - ) - - for name in organization_checks: - print( - _status_marker( - name.replace("_", " ").title(), - len(checks[name]), - ) ) - if next_version is None: - current_version = local_version(root) - entered = input( - f"Next semantic version [{current_version}]: " - ).strip() - next_version = entered or current_version - - answer = input( - "Write the reviewed integrity manifest? [y/N]: " - ).strip().casefold() + for name in organization_checks: + print( + self._status_marker( + name.replace("_", " ").title(), + len(checks[name]), + ) + ) - write_requested = answer in {"y", "yes"} + if next_version is None: + current_version = local_version(root) + entered = input( + f"Next semantic version [{current_version}]: " + ).strip() + next_version = entered or current_version - if arguments.write_manifest and next_version is None: - raise ValueError( - "--write-manifest requires --next-version unless " - "--interactive is used" - ) + answer = input( + "Write the reviewed integrity manifest? [y/N]: " + ).strip().casefold() - manifest_path: str | None = None + write_requested = answer in {"y", "yes"} - if write_requested: - if next_version is None: + if arguments.write_manifest and next_version is None: raise ValueError( - "a semantic next version is required to write " - "the integrity manifest" + "--write-manifest requires --next-version unless " + "--interactive is used" ) - manifest = build_manifest(root, settings, next_version) - legacy_ini = root / "CODE" / "config.ini" - modern_config = root / "logicytics.json" + manifest_path: str | None = None - if legacy_ini.is_file() and not modern_config.exists(): - written_manifest = write_legacy_ini_manifest(root, manifest) - else: - written_manifest = write_local_manifest( - root, - settings, - manifest, - ) + if write_requested: + if next_version is None: + raise ValueError( + "a semantic next version is required to write " + "the integrity manifest" + ) - manifest_path = str(written_manifest) + manifest = build_manifest(root, settings, next_version) + legacy_ini = root / "CODE" / "config.ini" + modern_config = root / "logicytics.json" - payload = { - "checks": checks, - "comparison": comparison, - "current_version": local_version(root), - "existing_manifest_version": ( - existing.version if existing else None - ), - "manifest_written": manifest_path, - "next_version": next_version, - } + if legacy_ini.is_file() and not modern_config.exists(): + written_manifest = write_legacy_ini_manifest(root, manifest) + else: + written_manifest = write_local_manifest( + root, + settings, + manifest, + ) + + manifest_path = str(written_manifest) + + payload = { + "checks": checks, + "comparison": comparison, + "current_version": local_version(root), + "existing_manifest_version": ( + existing.version if existing else None + ), + "manifest_written": manifest_path, + "next_version": next_version, + } - print(json.dumps(payload, indent=2, sort_keys=True)) - return 0 + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" - cli_parser = parser() + cli_parser = cli_methods.parser() arguments = cli_parser.parse_args(argv) standalone_actions = sum( @@ -600,7 +602,7 @@ def main(argv: list[str] | None = None) -> int: elif arguments.modes: arguments.command = "modes" - root = _project_root() + root = cli_methods.project_root() application_logger = None try: @@ -785,7 +787,7 @@ def main(argv: list[str] | None = None) -> int: payload["debug_log"] = str(debug_path) - _write_json( + cli_methods.write_json( debug_path, payload, ) @@ -865,7 +867,7 @@ def main(argv: list[str] | None = None) -> int: ) if arguments.new_window and update_succeeded: - launched_process_id = launch_action_window( + launched_process_id = cli_methods.launch_action_window( root, arguments.launch_action, ) @@ -888,7 +890,7 @@ def main(argv: list[str] | None = None) -> int: return 0 if update_succeeded else 1 if arguments.command == "dev": - return _run_developer_action( + return cli_methods.run_developer_action( root, configuration, arguments, @@ -896,7 +898,7 @@ def main(argv: list[str] | None = None) -> int: plan = build_plan( report, - request( + cli_methods.request( arguments, configuration.runtime.default_max_workers, ), @@ -1008,5 +1010,6 @@ def main(argv: list[str] | None = None) -> int: return 2 +cli_methods = CLI() if __name__ == "__main__": raise SystemExit(main()) diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py index e85a3182..c841703d 100644 --- a/tests/test_core_functionality.py +++ b/tests/test_core_functionality.py @@ -1,4 +1,5 @@ """Integration-style checks for the v4 core without shipped collectors.""" +# TODO Dedupe the logic, as its split weirdly from __future__ import annotations @@ -17,6 +18,7 @@ from dataclasses import FrozenInstanceError, replace from datetime import datetime from pathlib import Path +from typing import Any, Callable, cast from unittest.mock import MagicMock, patch from logicytics import ( @@ -26,10 +28,11 @@ query_run, read_artifact, run_collection, + maintenance, discovery, ) from logicytics import packaging from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.cli import launch_action_window, parser, request, main +from logicytics.cli import cli_methods, main, CLI from logicytics.command_runner import parse_level_messages, run_command from logicytics.configuration import ( LoggingSettings, @@ -78,6 +81,7 @@ from logicytics.output_layout import ensure_output_layout from logicytics.packaging import package_run from logicytics.planner import BUILTIN_PROFILES, build_plan +from logicytics.platform_adapters import ProcessAdapter, process_adapter from logicytics.runtime import RunSupervisor from logicytics.sysinternals import ensure_sysinternals @@ -139,7 +143,7 @@ def _plugin_collector_source() -> str: ) -def _mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, object]: +def _mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, Any]: """Return a complete sidecar declaration for a harmless legacy script fixture.""" return { "id": f"mod.{name}", @@ -384,8 +388,13 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( ) response = MagicMock() response.__enter__.return_value.read.return_value = payload - with patch("logicytics.maintenance.urllib.request.urlopen", return_value=response): + with patch.object( + maintenance.urllib.request, + "urlopen", + return_value=response, + ): manifest = fetch_remote_manifest(settings) + self.assertIsNotNone(manifest) self.assertEqual("4.1.0-snapshot.2", manifest.version) @@ -393,7 +402,11 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( remote_manifest_url=settings.remote_manifest_url, remote_manifest_sha256="0" * 64, ) - with patch("logicytics.maintenance.urllib.request.urlopen", return_value=response): + with patch.object( + maintenance.urllib.request, + "urlopen", + return_value=response, + ): with self.assertRaisesRegex(ValueError, "does not match"): fetch_remote_manifest(tampered) @@ -405,14 +418,18 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( "collectors": ["remote.code"], } ).encode("utf-8") + execution_response = MagicMock() execution_response.__enter__.return_value.read.return_value = execution_payload + execution_settings = MaintenanceSettings( remote_manifest_url=settings.remote_manifest_url, remote_manifest_sha256=hashlib.sha256(execution_payload).hexdigest(), ) - with patch( - "logicytics.maintenance.urllib.request.urlopen", + + with patch.object( + maintenance.urllib.request, + "urlopen", return_value=execution_response, ): with self.assertRaisesRegex(ValueError, "only schema_version"): @@ -458,17 +475,23 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No '[project]\nname = "fixture"\nversion = "4.0.0"\n', encoding="utf-8", ) - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", new_callable=io.StringIO + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + new_callable=io.StringIO, ): - self.assertEqual(0, main(["dev", "--write-manifest", "--next-version", "4.1.0"])) + self.assertEqual( + 0, + main(["dev", "--write-manifest", "--next-version", "4.1.0"]), + ) + manifest_path = root / "project.manifest.json" self.assertTrue(manifest_path.is_file()) manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) self.assertEqual("4.1.0", manifest_payload["version"]) - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", new_callable=io.StringIO + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + new_callable=io.StringIO, ): self.assertEqual(0, main(["debug"])) debug_path = root / "output" / "logs" / "debug" / "debug.json" @@ -504,8 +527,9 @@ def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> No encoding="utf-8", ) output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, ): self.assertEqual( 0, @@ -633,7 +657,7 @@ def test_native_mod_requires_explicit_filesystem_write_declaration(self) -> None ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("filesystem_write", report.invalid[0].runtime_error) + self.assertIn("filesystem_write", report.invalid[0].runtime_error or "") script.with_suffix(".bat.mod.json").write_text( json.dumps(_mod_metadata("native", filesystem_write=True)), @@ -676,9 +700,9 @@ def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers(self) - )) self.assertEqual(["mod.batch_mod", "mod.python_mod"], [item.metadata.id for item in modded.collectors]) self.assertEqual(["mod.batch_mod"], [item.metadata.id for item in nopy.collectors]) - parser = parser() - self.assertTrue(request(parser.parse_args(["run", "--modded"]), 2).enable_mods) - nopy_request = request(parser.parse_args(["run", "--nopy"]), 2) + parser = cli_methods.parser() + self.assertTrue(cli_methods.request(parser.parse_args(["run", "--modded"]), 2).enable_mods) + nopy_request = cli_methods.request(parser.parse_args(["run", "--nopy"]), 2) self.assertTrue(nopy_request.enable_mods) self.assertTrue(nopy_request.non_python_only) @@ -732,7 +756,10 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): self.assertEqual(0, main(["--match", "run a quick basic collection"])) payload = json.loads(output.getvalue()) self.assertEqual("minimal", payload["matched_flag"]) @@ -752,7 +779,10 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel encoding="utf-8", ) output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): self.assertEqual(0, main(["--match", "an exhaustive slow scan"])) payload = json.loads(output.getvalue()) self.assertEqual("depth", payload["matched_flag"]) @@ -764,7 +794,10 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel self.assertIn("device_name", history[0]) output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): self.assertEqual(0, main(["--usage"])) usage = json.loads(output.getvalue()) self.assertEqual(1, usage["total_interactions"]) @@ -825,34 +858,83 @@ def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(se report.invalid[0].static_errors, ) - def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging(self) -> None: + def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( + self, + ) -> None: """Power actions remain explicit and cannot run before durable package publication.""" - arguments = parser().parse_args([ - "run", "--shutdown", "--performance-check", "--acknowledge-authorization", + arguments = cli_methods.parser().parse_args([ + "run", + "--shutdown", + "--performance-check", + "--acknowledge-authorization", ]) - request = request(arguments, default_workers=4) + + request = cli_methods.request(arguments, default_workers=4) + self.assertEqual(PostRunAction.SHUTDOWN, request.post_run_action) self.assertEqual(OutputPolicy.PACKAGE, request.output_policy) self.assertEqual(1, request.max_workers) + with self.assertRaises(SystemExit): - parser().parse_args(["run", "--reboot", "--shutdown"]) + cli_methods.parser().parse_args([ + "run", + "--reboot", + "--shutdown", + ]) + with self.assertRaisesRegex(ValueError, "require packaged output"): - request(parser().parse_args(["run", "--reboot", "--no-package"]), default_workers=1) + cli_methods.request( + cli_methods.parser().parse_args([ + "run", + "--reboot", + "--no-package", + ]), + default_workers=1, + ) with tempfile.TemporaryDirectory() as temporary: - logger = FileEventLogger(Path(temporary) / "events.jsonl", run_id="run-" + "a" * 32) - manifest = type("Manifest", (), { - "status": RunStatus.SUCCEEDED, - "package": {"path": "evidence.zip", "sha256": "a" * 64}, - })() - completed = subprocess.CompletedProcess(["shutdown"], 0, "", "") - with patch("logicytics.runtime.process_adapter.run", return_value=completed) as command: - RunSupervisor._execute_post_run_action(PostRunAction.REBOOT, manifest, logger) + logger = FileEventLogger( + Path(temporary) / "events.jsonl", + run_id="run-" + "a" * 32, + ) + + manifest = type( + "Manifest", + (), + { + "status": RunStatus.SUCCEEDED, + "package": { + "path": "evidence.zip", + "sha256": "a" * 64, + }, + }, + )() + + completed = subprocess.CompletedProcess( + ["shutdown"], + 0, + "", + "", + ) + + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + return_value=completed, + ) as command: + RunSupervisor._execute_post_run_action( + PostRunAction.REBOOT, + manifest, + logger, + ) + self.assertEqual("shutdown", command.call_args.args[0][0]) self.assertEqual("/r", command.call_args.args[0][1]) self.assertEqual("60", command.call_args.args[0][3]) - def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity(self) -> None: + def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity( + self, + ) -> None: """Only an exact validation context may reuse an isolated runtime probe.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -862,12 +944,21 @@ def test_preflight_cache_requires_source_interpreter_contract_and_configuration_ initial = preflight(root, configuration_hash="configuration-a") self.assertEqual(1, len(initial.valid), initial.invalid) - with patch("logicytics.discovery.process_adapter.run", wraps=subprocess.run) as probe: + + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, + ) as probe: cached = preflight(root, configuration_hash="configuration-a") self.assertEqual(1, len(cached.valid), cached.invalid) probe.assert_not_called() - with patch("logicytics.discovery.process_adapter.run", wraps=subprocess.run) as probe: + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, + ) as probe: invalidated = preflight(root, configuration_hash="configuration-b") self.assertEqual(1, len(invalidated.valid), invalidated.invalid) self.assertEqual(1, probe.call_count) @@ -973,6 +1064,7 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> self.assertIsNotNone(collector.started_at) self.assertIsNotNone(collector.finished_at) self.assertIsNotNone(collector.duration_seconds) + assert collector.duration_seconds is not None self.assertGreaterEqual(collector.duration_seconds, 0) self.assertEqual("test artifact created", collector.summary) self.assertEqual((), collector.errors) @@ -987,13 +1079,16 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> with self.assertRaises(FrozenInstanceError): collector.status = "failed" - def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration(self) -> None: + def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration( + self, + ) -> None: """Callers can inspect redacted lifecycle timing and actionable failure details without parsing manifests.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) collector_path = root / "core" / "system" / "system_info.py" collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() + collector_path.write_text( _COLLECTOR.replace( ' output = context.workspace / "system.txt"', @@ -1002,34 +1097,81 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat ), encoding="utf-8", ) - outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) + + outcome = run_collection( + root, + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + snapshot = query_run(root, outcome.manifest.run_id) collector = snapshot.collectors[0] self.assertEqual("failed", collector.status) self.assertIsNotNone(collector.duration_seconds) + + assert collector.duration_seconds is not None + self.assertGreaterEqual(collector.duration_seconds, 0) self.assertIn("worker crashed", collector.summary) - self.assertTrue(any("fixture lifecycle failure" in error for error in collector.errors)) - self.assertEqual("core.system.system_info", collector.failure.collector_id) + self.assertTrue( + any( + "fixture lifecycle failure" in error + for error in collector.errors + ) + ) + self.assertEqual( + "core.system.system_info", + collector.failure.collector_id, + ) self.assertEqual("collect", collector.failure.operation) - self.assertIn("fixture lifecycle failure", collector.failure.platform_error) + self.assertIn( + "fixture lifecycle failure", + collector.failure.platform_error, + ) self.assertTrue(collector.failure.remediation) self.assertTrue(collector.failure.retry_safe) - collector_path.write_text(_COLLECTOR, encoding="utf-8") + collector_path.write_text( + _COLLECTOR, + encoding="utf-8", + ) + output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output - ), patch("builtins.input", return_value="") as final_prompt: + + with patch.object( + CLI, + "project_root", + return_value=root, + ), patch( + "sys.stdout", + output, + ), patch( + "builtins.input", + return_value="", + ) as final_prompt: exit_code = main( - ["run", "--default", "--interactive", "--acknowledge-authorization"] + [ + "run", + "--default", + "--interactive", + "--acknowledge-authorization", + ] ) + self.assertEqual(0, exit_code, output.getvalue()) final_prompt.assert_called_once_with("Press Enter to exit...") self.assertIn("Collectors:", output.getvalue()) - self.assertIn("core.system.system_info status=succeeded duration_seconds=", output.getvalue()) - self.assertIn("summary=test artifact created", output.getvalue()) + self.assertIn( + "core.system.system_info status=succeeded duration_seconds=", + output.getvalue(), + ) + self.assertIn( + "summary=test artifact created", + output.getvalue(), + ) def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links(self) -> None: """Persisted status lookup cannot leave its configured run or trust a forged manifest.""" @@ -1415,7 +1557,7 @@ def test_builtin_profiles_select_declared_members_and_reject_unknown_names(self) build_plan(report, RunRequest(profile="invented")) with patch("sys.stderr", new_callable=io.StringIO) as errors: with self.assertRaises(SystemExit): - parser().parse_args(["plan", "--profile", "invented"]) + cli_methods.parser().parse_args(["plan", "--profile", "invented"]) self.assertIn("invalid choice", errors.getvalue()) def test_offline_profile_rejects_network_collectors_even_with_explicit_approval(self) -> None: @@ -1464,10 +1606,11 @@ def test_preflight_rejects_unregistered_collector_resource_classes(self) -> None ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("resource_class", report.invalid[0].runtime_error) + self.assertIn("resource_class", report.invalid[0].runtime_error or "") diagnostic = report.invalid[0].diagnostics[0] self.assertEqual("runtime.contract", diagnostic.rule) self.assertEqual(str(collector_path), diagnostic.path) + assert diagnostic.line is not None self.assertGreater(diagnostic.line, 1) self.assertIn("resource_class", diagnostic.message) with self.assertRaises(PreflightError): @@ -1489,7 +1632,7 @@ def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("sensitive collectors", report.invalid[0].runtime_error) + self.assertIn("sensitive collectors", report.invalid[0].runtime_error or "") with self.assertRaises(PreflightError): build_plan(report, RunRequest()) @@ -1527,14 +1670,14 @@ def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) - def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" - events: list[tuple[str, str, dict[str, object]]] = [] + events: list[tuple[str, str, dict[str, Any]]] = [] class Logger: @staticmethod - def event(level: str, message: str, **fields: object) -> None: + def event(level: str, message: str, **fields: Any) -> None: events.append((level, message, fields)) - @deprecated(Logger(), removal_version="5.0", reason="replacement exists") + @deprecated(cast(Any, Logger()), removal_version="5.0", reason="replacement exists") def old() -> str: return "still works" @@ -1544,28 +1687,28 @@ def old() -> str: def test_exception_helper_logs_before_raising(self) -> None: """Exception helpers must preserve the requested exception type and context.""" - events: list[tuple[str, str, dict[str, object]]] = [] + events: list[tuple[str, str, dict[str, Any]]] = [] class Logger: @staticmethod - def event(level: str, message: str, **fields: object) -> None: + def event(level: str, message: str, **fields: Any) -> None: events.append((level, message, fields)) with self.assertRaises(ValueError): - raise_logged(Logger(), ValueError, "invalid setting", setting="workers") + raise_logged(cast(Any, Logger()), ValueError, "invalid setting", setting="workers") self.assertEqual("exception", events[0][0]) self.assertEqual("ValueError", events[0][2]["exception_type"]) def test_timed_decorator_records_function_lifecycle(self) -> None: """Timing instrumentation must report start and finish through EventLogger.""" - events: list[tuple[str, str, dict[str, object]]] = [] + events: list[tuple[str, str, dict[str, Any]]] = [] class Logger: @staticmethod - def event(level: str, message: str, **fields: object) -> None: + def event(level: str, message: str, **fields: Any) -> None: events.append((level, message, fields)) - @timed(Logger()) + @timed(cast(Any, Logger())) def add(left: int, right: int) -> int: return left + right @@ -1825,8 +1968,8 @@ def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_s ) configuration = load_config(root) self.assertEqual(settings, configuration.settings_for(collector_id)) - snapshot = configuration.to_manifest_dict() - manifest_settings = snapshot["collector_settings"][collector_id] + snapshot = cast(dict[str, Any], configuration.to_manifest_dict()) + manifest_settings = cast(dict[str, Any], snapshot["collector_settings"][collector_id]) self.assertEqual("[REDACTED]", manifest_settings["password"]) self.assertEqual("[REDACTED]", manifest_settings["nested"]["refresh_token"]) self.assertEqual("[REDACTED]", manifest_settings["nested"]["cookie"]) @@ -2064,19 +2207,22 @@ def test_configuration_validates_bounded_network_and_packet_settings(self) -> No def test_run_parser_accepts_performance_check(self) -> None: """The run command must expose the performance mode used by the request builder.""" - arguments = parser().parse_args(["run", "--performance-check"]) + arguments = cli_methods.parser().parse_args(["run", "--performance-check"]) self.assertTrue(arguments.performance_check) - request = request(arguments, default_workers=4) + request = cli_methods.request(arguments, default_workers=4) self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) - def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(self) -> None: + def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( + self, + ) -> None: """Direct execution never pulls unrelated profile members into its supervised run.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) core = root / "core" / "system" core.mkdir(parents=True) (root / "plugins").mkdir() + (core / "first.py").write_text( _delayed_collector_source("first", 0), encoding="utf-8", @@ -2085,9 +2231,16 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s _delayed_collector_source("second", 0), encoding="utf-8", ) + output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + + with patch.object( + CLI, + "project_root", + return_value=root, + ), patch( + "sys.stdout", + output, ): self.assertEqual( 0, @@ -2097,9 +2250,16 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s "--acknowledge-authorization", ]), ) - manifests = list((root / "output" / "data").glob("run-*/manifest.json")) + + manifests = list( + (root / "output" / "data").glob("run-*/manifest.json") + ) self.assertEqual(1, len(manifests)) - manifest = json.loads(manifests[0].read_text(encoding="utf-8")) + + manifest = json.loads( + manifests[0].read_text(encoding="utf-8") + ) + self.assertEqual( ["core.system.first"], [record["id"] for record in manifest["collectors"]], @@ -2115,6 +2275,7 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s ), encoding="utf-8", ) + report = preflight(root) plan = build_plan( report, @@ -2125,6 +2286,7 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s max_workers=1, ), ) + self.assertEqual( ["core.system.second", "core.system.dependent"], [candidate.metadata.id for candidate in plan.collectors], @@ -2132,7 +2294,7 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies(s def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" - parser = parser() + parser = cli_methods.parser() expected = { "standard": ("standard", 1, False, False, False), "balanced": ("standard", 4, False, False, False), @@ -2143,19 +2305,32 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None "non-python": ("standard", 4, True, True, False), "performance": ("standard", 1, False, False, True), } + self.assertEqual(set(expected), set(EXECUTION_MODES)) - self.assertEqual(set(expected), {item["name"] for item in mode_matrix()["modes"]}) + + modes = mode_matrix()["modes"] + assert isinstance(modes, list) + assert all(isinstance(item, dict) for item in modes) + + self.assertEqual( + set(expected), + {item["name"] for item in modes}, + ) + for name, contract in expected.items(): with self.subTest(mode=name): - request = request(parser.parse_args(["run", "--mode", name]), 4) + run_request = cli_methods.request( + parser.parse_args(["run", "--mode", name]), + 4, + ) self.assertEqual( contract, ( - request.profile, - request.max_workers, - request.enable_mods, - request.non_python_only, - request.performance_check, + run_request.profile, + run_request.max_workers, + run_request.enable_mods, + run_request.non_python_only, + run_request.performance_check, ), ) @@ -2168,14 +2343,31 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None "nopy": "--nopy", "performance_check": "--performance-check", } + for field, mode_name in LEGACY_MODE_ALIASES.items(): with self.subTest(alias=alias_flags[field]): - legacy = request(parser.parse_args(["run", alias_flags[field]]), 4) - named = request(parser.parse_args(["run", "--mode", mode_name]), 4) + legacy = cli_methods.request( + parser.parse_args(["run", alias_flags[field]]), + 4, + ) + named = cli_methods.request( + parser.parse_args(["run", "--mode", mode_name]), + 4, + ) self.assertEqual(named, legacy) with self.assertRaisesRegex(ValueError, "--profile"): - request(parser.parse_args(["run", "--mode", "quick", "--profile", "deep"]), 4) + cli_methods.request( + parser.parse_args([ + "run", + "--mode", + "quick", + "--profile", + "deep", + ]), + 4, + ) + with patch("sys.stderr"), self.assertRaises(SystemExit): parser.parse_args(["run", "--mode", "quick", "--minimal"]) @@ -2184,14 +2376,27 @@ def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", output + + with patch.object( + CLI, + "project_root", + return_value=root, + ), patch( + "sys.stdout", + output, ): self.assertEqual(0, main(["--modes"])) + payload = json.loads(output.getvalue()) + self.assertEqual(1, payload["schema_version"]) - self.assertEqual(list(EXECUTION_MODES), [item["name"] for item in payload["modes"]]) - self.assertTrue(all("legacy_aliases" in item for item in payload["modes"])) + self.assertEqual( + list(EXECUTION_MODES), + [item["name"] for item in payload["modes"]], + ) + self.assertTrue( + all("legacy_aliases" in item for item in payload["modes"]) + ) self.assertEqual([], payload["collectors"]) def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: @@ -2205,62 +2410,117 @@ def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> self.assertIn("run", rendered) with patch("sys.stderr", new_callable=io.StringIO) as errors: with self.assertRaises(SystemExit): - parser().parse_args(["run", "--default", "--performance-check"]) + cli_methods.parser().parse_args(["run", "--default", "--performance-check"]) self.assertIn("not allowed with argument", errors.getvalue()) - def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window(self) -> None: + def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( + self, + ) -> None: """The paired update options launch exactly one shell-free visible Windows action.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) (root / ".git").mkdir() - git = subprocess.CompletedProcess(["git", "--version"], 0, "git version 2.0\n", "") + + git = subprocess.CompletedProcess( + ["git", "--version"], + 0, + "git version 2.0\n", + "", + ) output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch( - "logicytics.cli.process_adapter.run", return_value=git - ), patch("logicytics.cli.launch_action_window", return_value=321) as launch, patch( - "sys.stdout", output + + with patch.object( + CLI, + CLI.project_root.__name__, + return_value=root, + ), patch.object( + process_adapter, + process_adapter.run.__name__, + return_value=git, + ), patch.object( + CLI, + CLI.launch_action_window.__name__, + return_value=321, + ) as launch, patch( + "sys.stdout", + output, ): self.assertEqual( 0, - main(["update", "--launch-action", "debug", "--new-window"]), + main([ + "update", + "--launch-action", + "debug", + "--new-window", + ]), ) + payload = json.loads(output.getvalue()) self.assertEqual("debug", payload["launched_action"]) self.assertEqual(321, payload["launched_process_id"]) launch.assert_called_once_with(root, "debug") - with patch("logicytics.cli._project_root", return_value=root), patch( - "sys.stdout", new_callable=io.StringIO + with patch.object( + CLI, + CLI.project_root.__name__, + return_value=root, + ), patch( + "sys.stdout", + new_callable=io.StringIO, ) as invalid_output: - self.assertEqual(2, main(["update", "--new-window"])) - self.assertIn("must be provided together", invalid_output.getvalue()) + self.assertEqual( + 2, + main(["update", "--new-window"]), + ) - def test_new_window_launcher_uses_current_interpreter_without_a_shell(self) -> None: + self.assertIn( + "must be provided together", + invalid_output.getvalue(), + ) + + def test_new_window_launcher_uses_current_interpreter_without_a_shell( + self, + ) -> None: """Visible maintenance windows preserve argument boundaries and repository cwd.""" root = Path("C:/repo").resolve() process = MagicMock(pid=42) - with patch("logicytics.cli.sys.platform", "win32"), patch( - "logicytics.cli.process_adapter.popen", return_value=process + + with patch( + "sys.platform", + "win32", + ), patch.object( + process_adapter, + process_adapter.popen.__name__, + return_value=process, ) as popen: - self.assertEqual(42, launch_action_window(root, "preflight")) + self.assertEqual( + 42, + cli_methods.launch_action_window(root, "preflight"), + ) + popen.assert_called_once_with( [sys.executable, "-m", "logicytics", "preflight"], cwd=root, shell=False, - creationflags=getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010), + creationflags=process_adapter.create_new_console, close_fds=True, ) - with patch("logicytics.cli.sys.platform", "linux"), self.assertRaisesRegex( - OSError, "only on Windows" + + with patch( + "sys.platform", + "linux", + ), self.assertRaisesRegex( + OSError, + "only on Windows", ): - launch_action_window(root, "debug") + cli_methods.launch_action_window(root, "debug") def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: """Execution policy is selectable directly instead of relying on compatibility modes.""" - parser = parser() - sequential = request(parser.parse_args(["run", "--sequential"]), default_workers=4) - parallel = request(parser.parse_args(["run", "--parallel"]), default_workers=4) - bounded_parallel = request( + parser = cli_methods.parser() + sequential = cli_methods.request(parser.parse_args(["run", "--sequential"]), default_workers=4) + parallel = cli_methods.request(parser.parse_args(["run", "--parallel"]), default_workers=4) + bounded_parallel = cli_methods.request( parser.parse_args(["run", "--parallel", "--workers", "3"]), default_workers=4, ) @@ -2273,7 +2533,7 @@ def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: """Contradictory worker policies fail before creating a collection plan.""" - parser = parser() + parser = cli_methods.parser() conflicts = ( (["run", "--sequential", "--workers", "2"], 4, "sequential execution"), (["run", "--sequential", "--threaded"], 4, "legacy --threaded"), @@ -2285,7 +2545,7 @@ def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: for arguments, default_workers, error in conflicts: with self.subTest(arguments=arguments, default_workers=default_workers): with self.assertRaisesRegex(ValueError, error): - request(parser.parse_args(arguments), default_workers=default_workers) + cli_methods.request(parser.parse_args(arguments), default_workers=default_workers) with patch("sys.stderr"), self.assertRaises(SystemExit): parser.parse_args(["run", "--sequential", "--parallel"]) @@ -2302,12 +2562,12 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( "resolved_plan": [collector_id], } manifest_path.write_text(json.dumps(valid_manifest), encoding="utf-8") - parser = parser() + parser = cli_methods.parser() with self.assertRaisesRegex(ValueError, "explicit --include"): - request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) + cli_methods.request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) with self.assertRaisesRegex(ValueError, "not present in the original"): - request( + cli_methods.request( parser.parse_args( ["run", "--rerun-from", str(manifest_path), "--include", "core.system.other"] ), @@ -2315,7 +2575,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( ) manifest_path.write_text(json.dumps({**valid_manifest, "status": "running"}), encoding="utf-8") with self.assertRaisesRegex(ValueError, "finalized"): - request( + cli_methods.request( parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), 2, ) @@ -2324,7 +2584,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( invalid_manifest = {**valid_manifest, "manifest_schema_version": schema_version} manifest_path.write_text(json.dumps(invalid_manifest), encoding="utf-8") with self.assertRaisesRegex(ValueError, "unsupported schema_version"): - request( + cli_methods.request( parser.parse_args( ["run", "--rerun-from", str(manifest_path), "--include", collector_id] ), @@ -2332,7 +2592,7 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( ) manifest_path.write_text("not json", encoding="utf-8") with self.assertRaisesRegex(ValueError, "cannot be loaded"): - request( + cli_methods.request( parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), 2, ) @@ -3009,7 +3269,7 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( original_package_path = Path(original.manifest.package["path"]) original_package = original_package_path.read_bytes() selected_id = "core.system.z_selected" - arguments = parser().parse_args( + arguments = cli_methods.parser().parse_args( [ "run", "--rerun-from", @@ -3020,7 +3280,7 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( "--sequential", ] ) - request = request(arguments, default_workers=4) + request = cli_methods.request(arguments, default_workers=4) self.assertEqual(original.manifest.run_id, request.rerun_from) rerun = RunSupervisor(root, configuration).run(build_plan(report, request)) @@ -3087,12 +3347,12 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) configuration = replace( default_config(root), - collector_settings={ + collector_settings=cast(Any, { "core.system.system_info": { "password": "evidence-password", "nested": {"access_token": "evidence-token"}, } - }, + }), ) outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual("succeeded", outcome.manifest.status.value) @@ -3135,10 +3395,17 @@ def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) - self.assertNotIn("crash-token", outcome.manifest.errors[0]["message"]) failure = outcome.manifest.collectors[0].failure self.assertIsNotNone(failure) - self.assertNotIn("crash-password", failure["platform_error"]) - self.assertNotIn("crash-token", failure["platform_error"]) - self.assertIn("[REDACTED]", failure["platform_error"]) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + assert failure is not None + platform_error = failure["platform_error"] + assert isinstance(platform_error, str) + + self.assertNotIn("crash-password", platform_error) + self.assertNotIn("crash-token", platform_error) + self.assertIn("[REDACTED]", platform_error) + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] self.assertEqual(failure, packaged_record["failure"]) diagnostics = "\n".join( @@ -3206,10 +3473,10 @@ def test_collector_resource_progress_persists_in_manifest_summary_and_performanc preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True, performance_check=True), ) - snapshots: list[dict[str, object]] = [] + snapshots: list[dict[str, Any]] = [] from logicytics.manifest import write_manifest as original_write_manifest - def capture_manifest(path: Path, manifest: object) -> None: + def capture_manifest(path: Path, manifest: Any) -> None: snapshots.append(json.loads(json.dumps(manifest.to_dict()))) original_write_manifest(path, manifest) @@ -3225,7 +3492,7 @@ def capture_manifest(path: Path, manifest: object) -> None: } for field, value in expected.items(): self.assertEqual(value, record.progress[field]) - self.assertGreater(record.progress["elapsed_seconds"], 0) + self.assertGreater(float(record.progress["elapsed_seconds"]), 0) self.assertTrue( any( snapshot["collectors"][0]["status"] == "running" @@ -3501,11 +3768,11 @@ def test_package_streams_large_evidence_and_hashes_with_bounded_reads(self) -> N artifact = outcome.manifest.artifact_list()[0] source = outcome.run_directory / "artifacts" / artifact.relative_path expected_package_path = Path(outcome.manifest.package["path"]).with_suffix(".zip.tmp") - original_open = Path.open + original_open = cast(Callable[..., Any], Path.open) read_sizes: list[tuple[Path, int]] = [] class BoundedReader: - def __init__(self, stream: object, path: Path) -> None: + def __init__(self, stream: Any, path: Path) -> None: self.stream = stream self.path = path @@ -3513,7 +3780,7 @@ def __enter__(self) -> "BoundedReader": self.stream.__enter__() return self - def __exit__(self, *arguments: object) -> object: + def __exit__(self, *arguments: Any) -> Any: return self.stream.__exit__(*arguments) def read(self, size: int = -1) -> bytes: @@ -3522,7 +3789,7 @@ def read(self, size: int = -1) -> bytes: read_sizes.append((self.path, size)) return self.stream.read(size) - def guarded_open(path: Path, *arguments: object, **options: object) -> object: + def guarded_open(path: Path, *arguments: Any, **options: Any) -> Any: stream = original_open(path, *arguments, **options) if path in {source, expected_package_path} and arguments and arguments[0] == "rb": return BoundedReader(stream, path) @@ -3563,7 +3830,10 @@ def test_parallel_completion_preserves_deterministic_manifest_order(self) -> Non records = {record.id: record for record in outcome.manifest.collectors} self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) - self.assertLess(records["core.system.z_fast"].finished_at, records["core.system.a_slow"].finished_at) + fast_finished = records["core.system.z_fast"].finished_at + slow_finished = records["core.system.a_slow"].finished_at + assert fast_finished is not None and slow_finished is not None + self.assertLess(fast_finished, slow_finished) def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: """First-class CLI policies determine real sequential versus bounded worker overlap.""" @@ -3578,7 +3848,7 @@ def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: _delayed_collector_source(filename, 0.3), encoding="utf-8", ) - arguments = parser().parse_args( + arguments = cli_methods.parser().parse_args( ["run", execution_mode, "--acknowledge-authorization"] ) request = request(arguments, default_workers=2) @@ -3592,8 +3862,10 @@ def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: self.assertEqual("succeeded", first.status, first.errors) self.assertEqual("succeeded", second.status, second.errors) if expect_overlap: + assert second.started_at is not None and first.finished_at is not None self.assertLess(second.started_at, first.finished_at) else: + assert first.finished_at is not None and second.started_at is not None self.assertLessEqual(first.finished_at, second.started_at) def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: @@ -3625,7 +3897,9 @@ def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: first = records["core.system.a_parallel"] serial = records["core.system.m_serial"] last = records["core.system.z_parallel"] + assert first.finished_at is not None and serial.started_at is not None self.assertLessEqual(first.finished_at, serial.started_at) + assert serial.finished_at is not None and last.started_at is not None self.assertLessEqual(serial.finished_at, last.started_at) def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: @@ -3655,6 +3929,7 @@ def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: self.assertEqual("succeeded", first.status, first.errors) self.assertEqual("succeeded", second.status, second.errors) + assert first.finished_at is not None and second.started_at is not None self.assertLessEqual(first.finished_at, second.started_at) def test_independent_resource_classes_preserve_bounded_parallelism(self) -> None: @@ -3681,7 +3956,10 @@ def test_independent_resource_classes_preserve_bounded_parallelism(self) -> None self.assertEqual("succeeded", records["core.system.a_disk"].status) self.assertEqual("succeeded", records["core.system.z_network"].status) - self.assertLess(records["core.system.z_network"].started_at, records["core.system.a_disk"].finished_at) + network_started = records["core.system.z_network"].started_at + disk_finished = records["core.system.a_disk"].finished_at + assert network_started is not None and disk_finished is not None + self.assertLess(network_started, disk_finished) def test_interactive_resource_class_runs_without_any_worker_overlap(self) -> None: """Interactive collectors require an exclusive deterministic scheduler window.""" @@ -3706,14 +3984,14 @@ def test_interactive_resource_class_runs_without_any_worker_overlap(self) -> Non outcome = RunSupervisor(root, default_config(root)).run(plan) records = {record.id: record for record in outcome.manifest.collectors} - self.assertLessEqual( - records["core.system.a_disk"].finished_at, - records["core.system.m_interactive"].started_at, - ) - self.assertLessEqual( - records["core.system.m_interactive"].finished_at, - records["core.system.z_general"].started_at, - ) + disk_finished = records["core.system.a_disk"].finished_at + interactive_started = records["core.system.m_interactive"].started_at + interactive_finished = records["core.system.m_interactive"].finished_at + general_started = records["core.system.z_general"].started_at + assert disk_finished is not None and interactive_started is not None + assert interactive_finished is not None and general_started is not None + self.assertLessEqual(disk_finished, interactive_started) + self.assertLessEqual(interactive_finished, general_started) def test_dependencies_finish_before_dependents_start(self) -> None: """Topological order must become an execution barrier under bounded parallelism.""" @@ -3742,6 +4020,7 @@ def test_dependencies_finish_before_dependents_start(self) -> None: self.assertEqual("succeeded", dependent.status, dependent.errors) self.assertIsNotNone(dependency.finished_at) self.assertIsNotNone(dependent.started_at) + assert dependency.finished_at is not None and dependent.started_at is not None self.assertLessEqual(dependency.finished_at, dependent.started_at) def test_dependency_closure_and_plan_fingerprint_are_deterministic(self) -> None: @@ -4014,10 +4293,22 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info" / "system.txt").is_file()) self.assertEqual(1, len(record.artifacts)) self.assertEqual(1, len(outcome.manifest.artifact_list())) - self.assertEqual("core.system.system_info", record.failure["collector_id"]) - self.assertEqual("collect", record.failure["operation"]) - self.assertIn("failure after evidence registration", record.failure["platform_error"]) - self.assertFalse(record.failure["retry_safe"]) + + failure = record.failure + assert failure is not None + + self.assertEqual("core.system.system_info", failure["collector_id"]) + self.assertEqual("collect", failure["operation"]) + + platform_error = failure["platform_error"] + assert isinstance(platform_error, str) + + self.assertIn( + "failure after evidence registration", + platform_error, + ) + + self.assertFalse(failure["retry_safe"]) with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) packaged = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] @@ -4122,6 +4413,7 @@ def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> No collector_path = root / "core" / "system" / "system_info.py" collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() + collector_path.write_text( _COLLECTOR.replace( ' """Release test resources."""', @@ -4130,19 +4422,60 @@ def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> No ), encoding="utf-8", ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) + + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + record = outcome.manifest.collectors[0] self.assertEqual("failed", record.status) self.assertEqual("collector cleanup failed", record.summary) self.assertEqual(1, len(record.artifacts)) - self.assertIn("finalizer failed after collecting evidence", "\n".join(record.errors)) - self.assertEqual("cleanup", record.failure["operation"]) - self.assertIn("cleanup", record.failure["remediation"]) - self.assertFalse(record.failure["retry_safe"]) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) + self.assertIn( + "finalizer failed after collecting evidence", + "\n".join(record.errors), + ) + + failure = record.failure + assert failure is not None + + self.assertEqual("cleanup", failure["operation"]) + + remediation = failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn("cleanup", remediation) + + retry_safe = failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertFalse(retry_safe) + + package = outcome.manifest.package + assert package is not None + assert "path" in package + + with zipfile.ZipFile(Path(package["path"])) as archive: + self.assertIn( + "evidence/derived/core_system_system_info/system.txt", + archive.namelist(), + ) def test_collector_timeout_preserves_independent_worker_results(self) -> None: """A timed-out worker is terminated without cancelling unrelated collection.""" @@ -4151,28 +4484,78 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: core_directory = root / "core" / "system" core_directory.mkdir(parents=True) (root / "plugins").mkdir() + timeout_source = _delayed_collector_source("a_timeout", 2.0).replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n timeout_seconds=1,', + ' supported_platforms=("win32",),\n' + ' timeout_seconds=1,', + ) + + (core_directory / "a_timeout.py").write_text( + timeout_source, + encoding="utf-8", ) - (core_directory / "a_timeout.py").write_text(timeout_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( _delayed_collector_source("z_independent", 0.0), encoding="utf-8", ) + report = preflight(root) self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("failed", records["core.system.a_timeout"].status) - self.assertTrue(any("timeout" in error for error in records["core.system.a_timeout"].errors)) - self.assertEqual("timeout_exceeded", records["core.system.a_timeout"].termination_reason) - self.assertTrue(records["core.system.a_timeout"].failure["retry_safe"]) - self.assertIn("timeout", records["core.system.a_timeout"].failure["remediation"]) - self.assertEqual("succeeded", records["core.system.z_independent"].status) - self.assertIsNone(records["core.system.z_independent"].failure) + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + records = { + record.id: record + for record in outcome.manifest.collectors + } + + timeout_record = records["core.system.a_timeout"] + + self.assertEqual("failed", timeout_record.status) + self.assertTrue( + any("timeout" in error for error in timeout_record.errors) + ) + self.assertEqual( + "timeout_exceeded", + timeout_record.termination_reason, + ) + + timeout_failure = timeout_record.failure + assert timeout_failure is not None + + retry_safe = timeout_failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertTrue(retry_safe) + + remediation = timeout_failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn("timeout", remediation) + + independent_record = records["core.system.z_independent"] + + self.assertEqual("succeeded", independent_record.status) + self.assertIsNone(independent_record.failure) self.assertEqual("partial", outcome.manifest.status.value) def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> None: @@ -4236,32 +4619,83 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( collector_path = root / "core" / "system" / "system_info.py" collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() + base = _COLLECTOR.replace( "from pathlib import Path\n", - "from pathlib import Path\nimport socket\nimport subprocess\nimport sys\nimport winreg\n", + "from pathlib import Path\n" + "import socket\n" + "import subprocess\n" + "import sys\n" + "import winreg\n", ) + attempts = ( - ("subprocess.run([sys.executable, '-c', 'pass'], check=False)", "subprocess capability"), - ("socket.socket()", "network capability"), - ("winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Software')", "registry_read capability"), + ( + "subprocess.run([sys.executable, '-c', 'pass'], check=False)", + "subprocess capability", + ), + ( + "socket.socket()", + "network capability", + ), + ( + "winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Software')", + "registry_read capability", + ), ) + for operation, message in attempts: with self.subTest(operation=operation): collector_path.write_text( base.replace( ' output = context.workspace / "system.txt"', - f" {operation}\n output = context.workspace / \"system.txt\"", + f' {operation}\n' + ' output = context.workspace / "system.txt"', ), encoding="utf-8", ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual("failed", outcome.manifest.collectors[0].status) - self.assertIn(message, "\n".join(outcome.manifest.collectors[0].errors)) - failure = outcome.manifest.collectors[0].failure + + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertIn( + message, + "\n".join(record.errors), + ) + + failure = record.failure + assert failure is not None + self.assertEqual("access", failure["operation"]) - self.assertIn("capability", failure["remediation"]) - self.assertFalse(failure["retry_safe"]) + + remediation = failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn("capability", remediation) + + retry_safe = failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertFalse(retry_safe) def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities(self) -> None: """External evidence reads require filesystem access plus every applicable sensitive grant.""" @@ -4555,30 +4989,93 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No core_directory = root / "core" / "system" core_directory.mkdir(parents=True) (root / "plugins").mkdir() + limited_source = _delayed_collector_source("a_limited", 2.0).replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n maximum_memory_bytes=1,', + ' supported_platforms=("win32",),\n' + ' maximum_memory_bytes=1,', ) - (core_directory / "a_limited.py").write_text(limited_source, encoding="utf-8") + + (core_directory / "a_limited.py").write_text( + limited_source, + encoding="utf-8", + ) + (core_directory / "z_independent.py").write_text( _delayed_collector_source("z_independent", 0.0), encoding="utf-8", ) + report = preflight(root) self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} + + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + records = { + record.id: record + for record in outcome.manifest.collectors + } limited = records["core.system.a_limited"] + self.assertEqual("failed", limited.status) - self.assertGreater(limited.peak_memory_bytes, 1) - self.assertTrue(any("maximum_memory_bytes=1" in error for error in limited.errors)) - self.assertEqual("memory_limit_exceeded", limited.termination_reason) - self.assertEqual("collect", limited.failure["operation"]) - self.assertIn("memory limit", limited.failure["remediation"]) - self.assertTrue(limited.failure["retry_safe"]) - self.assertEqual("succeeded", records["core.system.z_independent"].status) + + peak_memory_bytes = limited.peak_memory_bytes + assert peak_memory_bytes is not None + + self.assertGreater(peak_memory_bytes, 1) + self.assertTrue( + any( + "maximum_memory_bytes=1" in error + for error in limited.errors + ) + ) + self.assertEqual( + "memory_limit_exceeded", + limited.termination_reason, + ) + + limited_failure = limited.failure + assert limited_failure is not None + + self.assertEqual( + "collect", + limited_failure["operation"], + ) + + remediation = limited_failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn( + "memory limit", + remediation, + ) + + retry_safe = limited_failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertTrue(retry_safe) + + independent = records["core.system.z_independent"] + + self.assertEqual("succeeded", independent.status) self.assertEqual("partial", outcome.manifest.status.value) def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: @@ -4588,30 +5085,73 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: collector_path = root / "core" / "system" / "system_info.py" collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") + + collector_path.write_text( + _COLLECTOR, + encoding="utf-8", + ) + report = preflight(root) - plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - supervisor = RunSupervisor(root, default_config(root)) - with patch.object(supervisor, "_supervise", side_effect=KeyboardInterrupt): + + plan = build_plan( + report, + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + supervisor = RunSupervisor( + root, + default_config(root), + ) + + with patch.object( + supervisor, + supervisor._supervise.__name__, + side_effect=KeyboardInterrupt, + ): outcome = supervisor.run(plan) + self.assertEqual("cancelled", outcome.manifest.status.value) self.assertTrue(outcome.manifest.cancellation_requested) + record = outcome.manifest.collectors[0] + self.assertEqual("cancelled", record.status) self.assertEqual("run cancelled by user", record.summary) self.assertTrue((outcome.run_directory / ".cancelled").is_file()) - self.assertIsNotNone(outcome.manifest.package) - package_path = Path(outcome.manifest.package["path"]) + + package = outcome.manifest.package + assert package is not None + assert "path" in package + + package_path = Path(package["path"]) + self.assertTrue(package_path.is_file()) + with zipfile.ZipFile(package_path) as archive: - summary = archive.read("reports/summary.txt").decode("utf-8") - packaged_manifest = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read( + "reports/summary.txt" + ).decode("utf-8") + + packaged_manifest = json.loads( + archive.read("metadata/manifest.json") + ) + self.assertTrue(packaged_manifest["cancellation_requested"]) self.assertIn("Status: cancelled", summary) self.assertIn("Cancellation requested: true", summary) self.assertIn("Summary: run cancelled by user", summary) self.assertIn("Started: not started", summary) - self.assertIn(f"Finished: {record.finished_at}", summary) + + finished_at = record.finished_at + assert finished_at is not None + + self.assertIn( + f"Finished: {finished_at}", + summary, + ) def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -4633,25 +5173,91 @@ def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: core_path = root / "core" / "system" / "system_info.py" core_path.parent.mkdir(parents=True) core_path.write_text(_COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "broken_plugin.py" plugin_path.parent.mkdir() - plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + plugin_path.write_text( + '"""Invalid plugin collector."""\n', + encoding="utf-8", + ) + report = preflight(root) payload = report.to_dict() - self.assertEqual(["core.system.system_info"], [item["id"] for item in payload["valid"]]) - self.assertEqual([], payload["invalid"]) - self.assertEqual(["plugin.broken_plugin"], [item["id"] for item in payload["quarantined"]]) - diagnostic = payload["quarantined"][0]["diagnostics"][0] + + valid = payload["valid"] + assert isinstance(valid, list) + assert all(isinstance(item, dict) for item in valid) + + invalid = payload["invalid"] + assert isinstance(invalid, list) + + quarantined = payload["quarantined"] + assert isinstance(quarantined, list) + assert all(isinstance(item, dict) for item in quarantined) + + self.assertEqual( + ["core.system.system_info"], + [item["id"] for item in valid], + ) + self.assertEqual([], invalid) + self.assertEqual( + ["plugin.broken_plugin"], + [item["id"] for item in quarantined], + ) + + quarantined_item = quarantined[0] + + diagnostics = quarantined_item["diagnostics"] + assert isinstance(diagnostics, list) + assert all(isinstance(item, dict) for item in diagnostics) + + diagnostic = diagnostics[0] + self.assertEqual(str(plugin_path), diagnostic["path"]) self.assertEqual(1, diagnostic["line"]) self.assertEqual("static.class_name", diagnostic["rule"]) - self.assertIn("exactly one public collector class", diagnostic["message"]) - selected = report.to_dict(selected_plugins=("plugin.broken_plugin",)) - self.assertEqual([], selected["quarantined"]) - self.assertEqual(["plugin.broken_plugin"], [item["id"] for item in selected["invalid"]]) + + message = diagnostic["message"] + assert isinstance(message, str) + + self.assertIn( + "exactly one public collector class", + message, + ) + + selected = report.to_dict( + selected_plugins=("plugin.broken_plugin",), + ) + + selected_quarantined = selected["quarantined"] + assert isinstance(selected_quarantined, list) + + selected_invalid = selected["invalid"] + assert isinstance(selected_invalid, list) + assert all(isinstance(item, dict) for item in selected_invalid) + + self.assertEqual([], selected_quarantined) + self.assertEqual( + ["plugin.broken_plugin"], + [item["id"] for item in selected_invalid], + ) + enabled = report.to_dict(enable_plugins=True) - self.assertEqual(["plugin.broken_plugin"], [item["id"] for item in enabled["invalid"]]) - plan = build_plan(report, RunRequest()) + + enabled_invalid = enabled["invalid"] + assert isinstance(enabled_invalid, list) + assert all(isinstance(item, dict) for item in enabled_invalid) + + self.assertEqual( + ["plugin.broken_plugin"], + [item["id"] for item in enabled_invalid], + ) + + plan = build_plan( + report, + RunRequest(), + ) + self.assertEqual( ["core.system.system_info"], [candidate.metadata.id for candidate in plan.collectors], @@ -4694,7 +5300,10 @@ def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(se ): with self.subTest(arguments=arguments): output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch("sys.stdout", output): + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): exit_code = main(arguments) payload = json.loads(output.getvalue()) self.assertEqual(expected_exit, exit_code) @@ -4910,9 +5519,13 @@ def test_preflight_rejects_a_hanging_validation_worker(self) -> None: collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text(_COLLECTOR, encoding="utf-8") - with patch( - "logicytics.discovery.process_adapter.run", - side_effect=subprocess.TimeoutExpired(["validation-worker"], timeout=10), + with patch.object( + discovery.process_adapter, + discovery.process_adapter.run.__name__, + side_effect=subprocess.TimeoutExpired( + ["validation-worker"], + timeout=10, + ), ): report = preflight(root) self.assertEqual(1, len(report.invalid)) From ee05f1400a245b3ea3905b3a25b8834e4c753bbf Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 00:49:58 +0300 Subject: [PATCH 229/316] refactor: split the gigantic test_core_functionality.py to submodules as well as fixtures fixed some minor warns by pep as well Signed-off-by: DefinetlyNotAI --- tests/fixtures/__init__.py | 0 tests/fixtures/collectors.py | 141 + .../fixtures}/file_listing.py | 0 tests/test_artifact_writer.py | 360 ++ tests/test_cli.py | 287 + tests/test_configuration.py | 434 ++ tests/test_contracts.py | 136 + tests/test_core_functionality.py | 5696 ----------------- tests/test_interaction.py | 111 + tests/test_logging.py | 259 + tests/test_maintenance.py | 263 + tests/test_mods.py | 511 ++ tests/test_packaging.py | 882 +++ tests/test_planning.py | 591 ++ tests/test_preflight.py | 674 ++ tests/test_public_api.py | 389 ++ tests/test_runtime.py | 737 +++ tests/test_scheduler.py | 246 + tests/test_worker_security.py | 392 ++ 19 files changed, 6413 insertions(+), 5696 deletions(-) create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/collectors.py rename {logicytics => tests/fixtures}/file_listing.py (100%) create mode 100644 tests/test_artifact_writer.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_configuration.py create mode 100644 tests/test_contracts.py delete mode 100644 tests/test_core_functionality.py create mode 100644 tests/test_interaction.py create mode 100644 tests/test_logging.py create mode 100644 tests/test_maintenance.py create mode 100644 tests/test_mods.py create mode 100644 tests/test_packaging.py create mode 100644 tests/test_planning.py create mode 100644 tests/test_preflight.py create mode 100644 tests/test_public_api.py create mode 100644 tests/test_runtime.py create mode 100644 tests/test_scheduler.py create mode 100644 tests/test_worker_security.py diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/collectors.py b/tests/fixtures/collectors.py new file mode 100644 index 00000000..1910e434 --- /dev/null +++ b/tests/fixtures/collectors.py @@ -0,0 +1,141 @@ +"""Integration-style checks for the v4 core without shipped collectors.""" + +from __future__ import annotations + +import sys +from typing import Any + +from logicytics.contracts import ResourceClass + +COLLECTOR = """\ +\"\"\"Create a harmless test artifact.\"\"\" + +from pathlib import Path + +from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import EstimatedCost, NetworkAccess, PrivilegeLevel +from logicytics.contracts import CollectorContext + + +class SystemInfoCollector(CoreCollector): + \"\"\"A valid test core collector.\"\"\" + + @classmethod + def metadata(cls) -> CollectorMetadata: + \"\"\"Return test metadata.\"\"\" + return CollectorMetadata( + id="core.system.system_info", + name="System info", + version="1.0.0", + specialty=Specialty.SYSTEM, + output_media_types=("text/plain",), + description="Creates a harmless text artifact for core tests.", + author="tests", + supported_platforms=("win32",), + ) + + def validate(self, context: CollectorContext) -> ValidationResult: + \"\"\"Validate the test collector.\"\"\" + return ValidationResult(True) + + def collect(self, context: CollectorContext) -> CollectorResult: + \"\"\"Write and register a harmless artifact.\"\"\" + output = context.workspace / "system.txt" + output.write_text("ok\\n", encoding="utf-8") + artifact = context.artifacts.register_file(output, media_type="text/plain") + return CollectorResult.succeeded("test artifact created", (artifact,)) + + def cleanup(self, context: CollectorContext) -> None: + \"\"\"Release test resources.\"\"\" +""" + + +def plugin_collector_source() -> str: + """Convert the core fixture into a plugin with every security field explicit.""" + return COLLECTOR.replace("CoreCollector", "PluginCollector").replace( + ' author="tests",', + ' author="tests",\n' + ' capabilities=(),\n' + ' privilege_level=PrivilegeLevel.STANDARD,\n' + ' sensitive_data_categories=(),\n' + ' network_access=NetworkAccess.NONE,\n' + ' estimated_cost=EstimatedCost.LOW,\n' + ' timeout_seconds=60,\n' + ' maximum_output_bytes=100 * 1024 * 1024,\n' + ' minimum_contract_version="4.0",', + ) + + +def mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, Any]: + """Return a complete sidecar declaration for a harmless legacy script fixture.""" + return { + "id": f"mod.{name}", + "name": f"{name} mod", + "version": "1.0.0", + "specialty": "integration", + "description": "Harmless isolated legacy script fixture.", + "author": "tests", + "supported_platforms": [sys.platform], + "capabilities": [ + "subprocess", + *(["filesystem_write"] if filesystem_write else []), + ], + "privilege_level": "standard", + "sensitive_data_categories": [], + "network_access": "none", + "estimated_cost": "low", + "timeout_seconds": 15, + "maximum_output_bytes": 1024 * 1024, + "maximum_artifact_files": 10, + "output_media_types": ["text/plain"], + "minimum_contract_version": "4.0", + "default_profiles": ["standard"], + } + + +def delayed_collector_source( + filename: str, + delay: float, + *, + parallel_safe: bool = True, + dependencies: tuple[str, ...] = (), + resource_class: ResourceClass = ResourceClass.GENERAL, + fail: bool = False, +) -> str: + """Create a valid fixture collector with observable scheduling duration.""" + class_name = "".join(part.title() for part in filename.split("_")) + source = COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") + source = source.replace("core.system.system_info", f"core.system.{filename}") + source = source.replace( + "from pathlib import Path\n", + "from pathlib import Path\nfrom time import sleep\n", + ) + source = source.replace( + "from logicytics import CollectorMetadata,", + "from logicytics import CollectorMetadata, ResourceClass,", + ) + source = source.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + f" dependencies={dependencies!r},\n" + f" resource_class=ResourceClass.{resource_class.name},\n" + f" parallel_safe={parallel_safe!r},", + ) + source = source.replace( + " def validate(self, context: CollectorContext) -> ValidationResult:\n", + " @classmethod\n" + " def dependencies(cls) -> tuple[str, ...]:\n" + ' """Return fixture dependencies for scheduler tests."""\n' + f" return {dependencies!r}\n\n" + " def validate(self, context: CollectorContext) -> ValidationResult:\n", + ) + source = source.replace( + ' output = context.workspace / "system.txt"', + f' sleep({delay})\n output = context.workspace / "system.txt"', + ) + if fail: + source = source.replace( + f' sleep({delay})\n output = context.workspace / "system.txt"', + f' sleep({delay})\n raise RuntimeError("dependency fixture failed")', + ) + return source diff --git a/logicytics/file_listing.py b/tests/fixtures/file_listing.py similarity index 100% rename from logicytics/file_listing.py rename to tests/fixtures/file_listing.py diff --git a/tests/test_artifact_writer.py b/tests/test_artifact_writer.py new file mode 100644 index 00000000..79aec9f6 --- /dev/null +++ b/tests/test_artifact_writer.py @@ -0,0 +1,360 @@ +from __future__ import annotations + +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +from fixtures.collectors import COLLECTOR +from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.configuration import ( + default_config, +) +from logicytics.contracts import ( + EvidenceKind, + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.errors import ArtifactError +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class ArtifactWriterTests(unittest.TestCase): + """Artifact registration, ownership, limits, and concurrent budget behavior.""" + + def test_artifacts_cannot_escape_collector_workspace(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + outside = root / "outside.txt" + outside.write_text("no", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) + with self.assertRaises(ArtifactError): + writer.register_file(outside) + linked_source = workspace / "linked-source.txt" + linked_source.symlink_to(outside) + with self.assertRaisesRegex(ArtifactError, "collector workspace"): + writer.register_file(linked_source) + + def test_artifact_destination_symlink_cannot_escape_run_store(self) -> None: + """Pre-existing artifact junctions or symlinks must never redirect evidence outside the run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + outside = root / "outside" + workspace.mkdir() + artifact_root.mkdir() + outside.mkdir() + source = workspace / "report.txt" + source.write_text("evidence\n", encoding="utf-8") + (artifact_root / "core_system_test").symlink_to(outside, target_is_directory=True) + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) + + with self.assertRaisesRegex(ArtifactError, "destination"): + writer.register_file(source) + self.assertEqual([], list(outside.iterdir())) + + def test_artifact_writer_enforces_declared_output_name_and_format(self) -> None: + """A core collector cannot silently drift from its published output contract.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + expected = workspace / "system_info.json" + unexpected = workspace / "renamed.json" + expected.write_text("{}\n", encoding="utf-8") + unexpected.write_text("{}\n", encoding="utf-8") + writer = WorkspaceArtifactWriter( + "core.system.system_info", + workspace, + artifact_root, + 1024, + 2, + allowed_relative_paths=("system_info.json",), + allowed_media_types=("application/json",), + ) + + with self.assertRaisesRegex(ArtifactError, "output contract"): + writer.register_file(unexpected, media_type="application/json") + with self.assertRaisesRegex(ArtifactError, "media type"): + writer.register_file(expected, media_type="text/plain") + artifact = writer.register_file(expected, media_type="application/json") + self.assertEqual("core_system_system_info/system_info.json", artifact.relative_path) + + def test_cancelled_artifact_copy_never_publishes_partial_evidence(self) -> None: + """Cancellation must stop staging without creating a final or temporary artifact.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "report.txt" + source.write_text("evidence\n", encoding="utf-8") + cancellation_file = root / ".cancelled" + cancellation_file.touch() + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 1024, + 1, + cancellation_file=cancellation_file, + ) + + with self.assertRaisesRegex(ArtifactError, "cancelled"): + writer.register_file(source) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list(artifact_root.rglob("*"))) + + def test_mutating_artifact_source_cannot_bypass_declared_byte_limits(self) -> None: + """Growing an evidence file after its initial size check must not publish partial data.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "report.bin" + source.write_bytes(b"1234") + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 4, + 1, + ) + original_copy = writer._copy_artifact + + def mutate_before_copy(*arguments): + source.write_bytes(b"12345") + return original_copy(*arguments) + + with patch.object(writer, "_copy_artifact", side_effect=mutate_before_copy): + with self.assertRaisesRegex(ArtifactError, "maximum_artifact_bytes"): + writer.register_file(source) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list((artifact_root / "core_system_test").iterdir())) + + def test_cancellation_during_streaming_removes_incomplete_artifact(self) -> None: + """Mid-transfer cancellation must remove temporary evidence and publish nothing.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "large.bin" + source.write_bytes(b"x" * (2 * 1024 * 1024)) + cancellation_file = root / ".cancelled" + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 3 * 1024 * 1024, + 1, + cancellation_file=cancellation_file, + ) + original_check = writer._check_cancellation + checks = 0 + + def cancel_during_second_chunk() -> None: + nonlocal checks + checks += 1 + if checks == 3: + cancellation_file.touch() + original_check() + + with patch.object(writer, "_check_cancellation", side_effect=cancel_during_second_chunk): + with self.assertRaisesRegex(ArtifactError, "cancelled"): + writer.register_file(source) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list((artifact_root / "core_system_test").iterdir())) + + def test_artifact_registration_enforces_collector_file_count_limit(self) -> None: + """A collector cannot exceed its declared artifact count even with tiny files.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + first = workspace / "first.txt" + second = workspace / "second.txt" + first.write_text("one", encoding="utf-8") + second.write_text("two", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) + writer.register_file(first, media_type="text/plain") + with self.assertRaisesRegex(ArtifactError, "maximum_artifact_files"): + writer.register_file(second, media_type="text/plain") + + def test_concurrent_artifact_registration_allocates_unique_owned_paths(self) -> None: + """Simultaneous registration of one source cannot overwrite another catalog entry.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "shared.txt" + source.write_text("evidence", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 20) + + with ThreadPoolExecutor(max_workers=10) as executor: + artifacts = list(executor.map(lambda _: writer.register_file(source), range(20))) + + paths = [artifact.relative_path for artifact in artifacts] + self.assertEqual(20, len(set(paths))) + self.assertEqual(20, len(writer.artifacts)) + self.assertTrue(all((artifact_root / path).read_text(encoding="utf-8") == "evidence" for path in paths)) + + def test_concurrent_artifact_registration_cannot_bypass_file_or_byte_limits(self) -> None: + """Parallel callers observe one atomic quota and cannot overfill evidence storage.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "shared.txt" + source.write_text("123", encoding="utf-8") + writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 6, 2) + + def register(_: int) -> bool: + try: + writer.register_file(source) + except ArtifactError: + return False + return True + + with ThreadPoolExecutor(max_workers=12) as executor: + outcomes = list(executor.map(register, range(24))) + + self.assertEqual(2, sum(outcomes)) + self.assertEqual(2, len(writer.artifacts)) + self.assertEqual(6, sum(artifact.size_bytes for artifact in writer.artifacts)) + self.assertEqual(2, len(list((artifact_root / "core_system_test").iterdir()))) + + def test_artifact_registration_enforces_individual_file_size_limit(self) -> None: + """A collector-specific file ceiling rejects large evidence before it is copied.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + oversized = workspace / "oversized.bin" + oversized.write_bytes(b"12345") + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 1024, + 5, + maximum_artifact_bytes=4, + ) + with self.assertRaisesRegex(ArtifactError, "maximum_artifact_bytes"): + writer.register_file(oversized) + self.assertEqual((), writer.artifacts) + self.assertEqual([], list(artifact_root.rglob("*"))) + + def test_artifact_registration_enforces_reserved_run_output_budget(self) -> None: + """A worker cannot copy evidence beyond its supervisor-reserved run quota.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "oversized.bin" + source.write_bytes(b"12345") + writer = WorkspaceArtifactWriter( + "core.system.test", + workspace, + artifact_root, + 1024, + 5, + run_output_budget_bytes=4, + ) + with self.assertRaisesRegex(ArtifactError, "maximum_run_output_bytes"): + writer.register_file(source) + self.assertEqual([], list(artifact_root.rglob("*"))) + + def test_isolated_worker_enforces_declared_individual_artifact_limit(self) -> None: + """Metadata file limits must survive preflight and reach the isolated worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_artifact_bytes=2,', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + + record = outcome.manifest.collectors[0] + self.assertEqual("failed", record.status) + self.assertTrue(any("maximum_artifact_bytes" in error for error in record.errors)) + self.assertEqual([], record.artifacts) + + def test_artifact_registration_records_validated_provenance(self) -> None: + """Artifact provenance is typed, immutable, and completed by the writer.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + workspace = root / "workspace" + artifact_root = root / "artifacts" + workspace.mkdir() + artifact_root.mkdir() + source = workspace / "report.json" + source.write_text("{}\n", encoding="utf-8") + with self.assertRaisesRegex(ArtifactError, "source_category"): + WorkspaceArtifactWriter("invalid", workspace, artifact_root, 1024, 1) + writer = WorkspaceArtifactWriter( + "plugin.example", + workspace, + artifact_root, + 1024, + 1, + source_category="evidence_graph", + ) + with self.assertRaisesRegex(ArtifactError, "transformations"): + writer.register_file(source, transformations=["normalized"]) # type: ignore[arg-type] + with self.assertRaisesRegex(ArtifactError, "media_type"): + writer.register_file(source, media_type="not a mime type") + with self.assertRaisesRegex(ArtifactError, "evidence_kind"): + writer.register_file(source, evidence_kind="raw") # type: ignore[arg-type] + self.assertEqual((), writer.artifacts) + artifact = writer.register_file( + source, + evidence_kind=EvidenceKind.RAW, + transformations=("normalized",), + ) + self.assertEqual("report.json", artifact.name) + self.assertEqual("registered", artifact.status) + self.assertEqual("evidence_graph", artifact.source_category) + self.assertEqual(EvidenceKind.RAW, artifact.evidence_kind) + datetime.fromisoformat(artifact.collected_at) + self.assertEqual( + ("normalized", "copied into run artifact store"), + artifact.transformations, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..dae9ddff --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import io +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +from logicytics.cli import CLI, cli_methods, main +from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix +from logicytics.platform_adapters import process_adapter + + +class CliTests(unittest.TestCase): + """CLI modes, parser constraints, reruns, and launch behavior.""" + + def test_run_parser_accepts_performance_check(self) -> None: + """The run command must expose the performance mode used by the request builder.""" + arguments = cli_methods.parser().parse_args(["run", "--performance-check"]) + self.assertTrue(arguments.performance_check) + request = cli_methods.request(arguments, default_workers=4) + self.assertTrue(request.performance_check) + self.assertEqual(1, request.max_workers) + + def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: + """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" + parser = cli_methods.parser() + expected = { + "standard": ("standard", 1, False, False, False), + "balanced": ("standard", 4, False, False, False), + "quick": ("minimal", 4, False, False, False), + "thorough": ("deep", 4, False, False, False), + "offline": ("offline", 4, False, False, False), + "extensions": ("standard", 4, True, False, False), + "non-python": ("standard", 4, True, True, False), + "performance": ("standard", 1, False, False, True), + } + + self.assertEqual(set(expected), set(EXECUTION_MODES)) + + modes = mode_matrix()["modes"] + assert isinstance(modes, list) + assert all(isinstance(item, dict) for item in modes) + + self.assertEqual( + set(expected), + {item["name"] for item in modes}, + ) + + for name, contract in expected.items(): + with self.subTest(mode=name): + run_request = cli_methods.request( + parser.parse_args(["run", "--mode", name]), + 4, + ) + self.assertEqual( + contract, + ( + run_request.profile, + run_request.max_workers, + run_request.enable_mods, + run_request.non_python_only, + run_request.performance_check, + ), + ) + + alias_flags = { + "default_mode": "--default", + "threaded": "--threaded", + "minimal": "--minimal", + "depth": "--depth", + "modded": "--modded", + "nopy": "--nopy", + "performance_check": "--performance-check", + } + + for field, mode_name in LEGACY_MODE_ALIASES.items(): + with self.subTest(alias=alias_flags[field]): + legacy = cli_methods.request( + parser.parse_args(["run", alias_flags[field]]), + 4, + ) + named = cli_methods.request( + parser.parse_args(["run", "--mode", mode_name]), + 4, + ) + self.assertEqual(named, legacy) + + with self.assertRaisesRegex(ValueError, "--profile"): + cli_methods.request( + parser.parse_args([ + "run", + "--mode", + "quick", + "--profile", + "deep", + ]), + 4, + ) + + with patch("sys.stderr"), self.assertRaises(SystemExit): + parser.parse_args(["run", "--mode", "quick", "--minimal"]) + + def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: + """Users and release checks can inspect the same authoritative mode registry.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + + with patch.object( + CLI, + "project_root", + return_value=root, + ), patch( + "sys.stdout", + output, + ): + self.assertEqual(0, main(["--modes"])) + + payload = json.loads(output.getvalue()) + + self.assertEqual(1, payload["schema_version"]) + self.assertEqual( + list(EXECUTION_MODES), + [item["name"] for item in payload["modes"]], + ) + self.assertTrue( + all("legacy_aliases" in item for item in payload["modes"]) + ) + self.assertEqual([], payload["collectors"]) + + def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: + """An empty invocation is useful while contradictory legacy actions fail immediately.""" + output = io.StringIO() + with patch("sys.stdout", output): + self.assertEqual(0, main([])) + rendered = output.getvalue() + self.assertIn("Logicytics v4 run-oriented evidence framework", rendered) + self.assertIn("preflight", rendered) + self.assertIn("run", rendered) + with patch("sys.stderr", new_callable=io.StringIO) as errors: + with self.assertRaises(SystemExit): + cli_methods.parser().parse_args(["run", "--default", "--performance-check"]) + self.assertIn("not allowed with argument", errors.getvalue()) + + def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( + self, + ) -> None: + """The paired update options launch exactly one shell-free visible Windows action.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / ".git").mkdir() + + git = subprocess.CompletedProcess( + ["git", "--version"], + 0, + "git version 2.0\n", + "", + ) + output = io.StringIO() + + with patch.object( + CLI, + CLI.project_root.__name__, + return_value=root, + ), patch.object( + process_adapter, + process_adapter.run.__name__, + return_value=git, + ), patch.object( + CLI, + CLI.launch_action_window.__name__, + return_value=321, + ) as launch, patch( + "sys.stdout", + output, + ): + self.assertEqual( + 0, + main([ + "update", + "--launch-action", + "debug", + "--new-window", + ]), + ) + + payload = json.loads(output.getvalue()) + self.assertEqual("debug", payload["launched_action"]) + self.assertEqual(321, payload["launched_process_id"]) + launch.assert_called_once_with(root, "debug") + + with patch.object( + CLI, + CLI.project_root.__name__, + return_value=root, + ), patch( + "sys.stdout", + new_callable=io.StringIO, + ) as invalid_output: + self.assertEqual( + 2, + main(["update", "--new-window"]), + ) + + self.assertIn( + "must be provided together", + invalid_output.getvalue(), + ) + + def test_new_window_launcher_uses_current_interpreter_without_a_shell( + self, + ) -> None: + """Visible maintenance windows preserve argument boundaries and repository cwd.""" + root = Path("C:/repo").resolve() + process = MagicMock(pid=42) + + with patch( + "sys.platform", + "win32", + ), patch.object( + process_adapter, + process_adapter.popen.__name__, + return_value=process, + ) as popen: + self.assertEqual( + 42, + cli_methods.launch_action_window(root, "preflight"), + ) + + popen.assert_called_once_with( + [sys.executable, "-m", "logicytics", "preflight"], + cwd=root, + shell=False, + creationflags=process_adapter.create_new_console, + close_fds=True, + ) + + with patch( + "sys.platform", + "linux", + ), self.assertRaisesRegex( + OSError, + "only on Windows", + ): + cli_methods.launch_action_window(root, "debug") + + def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: + """Execution policy is selectable directly instead of relying on compatibility modes.""" + parser = cli_methods.parser() + sequential = cli_methods.request(parser.parse_args(["run", "--sequential"]), default_workers=4) + parallel = cli_methods.request(parser.parse_args(["run", "--parallel"]), default_workers=4) + bounded_parallel = cli_methods.request( + parser.parse_args(["run", "--parallel", "--workers", "3"]), + default_workers=4, + ) + + self.assertEqual(1, sequential.max_workers) + self.assertEqual(4, parallel.max_workers) + self.assertEqual(3, bounded_parallel.max_workers) + self.assertEqual("standard", sequential.profile) + self.assertEqual("standard", parallel.profile) + + def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: + """Contradictory worker policies fail before creating a collection plan.""" + parser = cli_methods.parser() + conflicts = ( + (["run", "--sequential", "--workers", "2"], 4, "sequential execution"), + (["run", "--sequential", "--threaded"], 4, "legacy --threaded"), + (["run", "--parallel", "--performance-check"], 4, "performance/default"), + (["run", "--parallel", "--default"], 4, "performance/default"), + (["run", "--parallel", "--workers", "1"], 4, "at least two"), + (["run", "--parallel"], 1, "at least two"), + ) + for arguments, default_workers, error in conflicts: + with self.subTest(arguments=arguments, default_workers=default_workers): + with self.assertRaisesRegex(ValueError, error): + cli_methods.request(parser.parse_args(arguments), default_workers=default_workers) + with patch("sys.stderr"), self.assertRaises(SystemExit): + parser.parse_args(["run", "--sequential", "--parallel"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_configuration.py b/tests/test_configuration.py new file mode 100644 index 00000000..a03e7746 --- /dev/null +++ b/tests/test_configuration.py @@ -0,0 +1,434 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +import zipfile +from pathlib import Path +from typing import Any, cast + +from fixtures.collectors import COLLECTOR +from logicytics.configuration import ( + default_config, + load_config, +) +from logicytics.contracts import ( + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.errors import PlanError +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class ConfigurationTests(unittest.TestCase): + """Configuration parsing, migration, validation, and manifest behavior.""" + + def test_configuration_schema_version_is_enforced(self) -> None: + """Current v4 and explicitly migrated v3 schemas are accepted; other versions are not.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + for unsupported in (0, 1, 2, 5): + with self.subTest(schema_version=unsupported): + config_path.write_text(json.dumps({"schema_version": unsupported}), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "unsupported configuration schema_version"): + load_config(root) + config_path.write_text('{"schema_version": 3}', encoding="utf-8") + migrated = load_config(root) + self.assertEqual(4, migrated.schema_version) + self.assertEqual(3, migrated.migrated_from_schema) + config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") + current = load_config(root) + self.assertEqual(4, current.schema_version) + self.assertIsNone(current.migrated_from_schema) + config_path.write_text('{"schema_version":4,"runtime":{"package_completed_runs":"yes"}}', encoding="utf-8") + with self.assertRaisesRegex(PlanError, "package_completed_runs"): + load_config(root) + + def test_legacy_configuration_migrates_runtime_and_collector_aliases_without_writing(self) -> None: + """A supported v3 configuration migrates once in memory and preserves its source bytes.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + settings = {"core.packet.packet_capture": {"packet_count": 7, "timeout_seconds": 5}} + original = json.dumps({ + "schema_version": 3, + "workers": 3, + "max_workers": 6, + "output_root": "ACCESS/RUNS", + "collector_settings": settings, + }, indent=2) + config_path.write_text(original, encoding="utf-8") + configuration = load_config(root) + + self.assertEqual(4, configuration.schema_version) + self.assertEqual(3, configuration.migrated_from_schema) + self.assertEqual(3, configuration.runtime.default_max_workers) + self.assertEqual(6, configuration.runtime.maximum_workers) + self.assertEqual(root / "output" / "data", configuration.runtime.output_root) + self.assertEqual(settings["core.packet.packet_capture"], + configuration.settings_for("core.packet.packet_capture")) + self.assertEqual(original, config_path.read_text(encoding="utf-8")) + + def test_historical_code_config_ini_migrates_into_typed_v4_settings(self) -> None: + """The original CODE/config.ini is a bounded read-only fallback when JSON is absent.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + code = root / "CODE" + code.mkdir() + config_path = code / "config.ini" + original = """[Settings] +log_using_debug = true +delete_old_logs = true +max_workers = 6 +save_preferences = false + +[Flag Settings] +accuracy_min = 30.0 +model_to_use = all-MiniLM-L6-v2 +model_debug = true + +[DumpMemory Settings] +file_size_limit = 8 +file_size_safety = 1.5 + +[NetWorkPsutil Settings] +sample_count = 5 +interval = 1.5 + +[PacketSniffer Settings] +interface = WiFi +packet_count = 5000 +timeout = 10 +max_retry_time = 30 +""" + config_path.write_text(original, encoding="utf-8") + + configuration = load_config(root) + + self.assertEqual(3, configuration.migrated_from_schema) + self.assertEqual(6, configuration.runtime.default_max_workers) + self.assertEqual(6, configuration.runtime.maximum_workers) + self.assertEqual("DEBUG", configuration.logging.level) + self.assertTrue(configuration.logging.delete_previous) + self.assertFalse(configuration.interaction.history_enabled) + self.assertEqual(0.3, configuration.interaction.similarity_threshold) + self.assertEqual("all-MiniLM-L6-v2", configuration.interaction.model_name) + self.assertTrue(configuration.interaction.model_debug) + self.assertEqual( + { + "output_limit_bytes": 8 * 1024 * 1024, + "disk_safety_margin_bytes": 4 * 1024 * 1024, + "dump_directory": "memory_maps", + }, + configuration.settings_for("core.process.memory_map"), + ) + self.assertEqual( + {"sample_count": 5, "interval_seconds": 1.5}, + configuration.settings_for("core.network.bandwidth_sample"), + ) + self.assertEqual(5000, configuration.settings_for("core.packet.packet_capture")["packet_count"]) + self.assertEqual(original, config_path.read_text(encoding="utf-8")) + + (root / "logicytics.json").write_text('{"schema_version":4}', encoding="utf-8") + self.assertIsNone(load_config(root).migrated_from_schema) + self.assertFalse((root / "output").exists()) + self.assertEqual(3, configuration.to_manifest_dict()["migrated_from_schema"]) + + def test_legacy_configuration_rejects_ambiguous_unsafe_and_plugin_enabling_migrations(self) -> None: + """Migration cannot override settings, weaken validation, or silently enable extensions.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid = ( + ({"workers": 2, "worker_count": 3}, "conflicting settings"), + ({"workers": 2, "runtime": {"default_max_workers": 2}}, "conflicting settings"), + ({"runtime": {"workers": 2, "worker_count": 3}}, "conflicting settings"), + ({"collectors": {}, "collector_settings": {}}, "conflicting collectors"), + ({"enable_plugins": True}, "unsupported root"), + ({"workers": True}, "worker limits"), + ({"collector_settings": {"core.process.memory_map": {"dump_directory": "../outside"}}}, + "collector-workspace"), + ({"collector_settings": {"core.packet.packet_capture": {"packet_count": 0}}}, "packet_count"), + ) + for legacy, message in invalid: + with self.subTest(legacy=legacy): + config_path.write_text(json.dumps({"schema_version": 3, **legacy}), encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + + def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evidence(self) -> None: + """Migrated collection stays isolated, packages normally, and never moves old evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + + legacy = root / "ACCESS" / "RUNS" / "previous.txt" + legacy.parent.mkdir(parents=True) + legacy.write_text("keep legacy evidence", encoding="utf-8") + + config_path = root / "logicytics.json" + original = json.dumps( + { + "schema_version": 3, + "worker_count": 1, + "output_root": "ACCESS/RUNS", + } + ) + config_path.write_text(original, encoding="utf-8") + + configuration = load_config(root) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + outcome = RunSupervisor(root, configuration).run(plan) + + self.assertEqual(root / "output" / "data", outcome.run_directory.parent) + self.assertEqual(3, outcome.manifest.configuration["migrated_from_schema"]) + self.assertEqual("keep legacy evidence", legacy.read_text(encoding="utf-8")) + self.assertEqual(original, config_path.read_text(encoding="utf-8")) + + package = outcome.manifest.package + if package is None: + self.fail("expected the completed run to contain package metadata") + + with zipfile.ZipFile(Path(package["path"])) as archive: + packaged = json.loads(archive.read("metadata/manifest.json")) + + self.assertEqual(4, packaged["configuration"]["schema_version"]) + self.assertEqual(3, packaged["configuration"]["migrated_from_schema"]) + + def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: + """Defaults and loaded settings share output/data while explicit roots remain supported.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + expected = root / "output" / "data" + self.assertEqual(expected, default_config(root).runtime.output_root) + self.assertEqual(expected, load_config(root).runtime.output_root) + config_path = root / "logicytics.json" + config_path.write_text('{"schema_version":4,"runtime":{}}', encoding="utf-8") + self.assertEqual(expected, load_config(root).runtime.output_root) + config_path.write_text( + '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', + encoding="utf-8", + ) + self.assertEqual(root / "custom" / "evidence", load_config(root).runtime.output_root) + self.assertFalse(expected.exists()) + + def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings(self) -> None: + """Manifest snapshots hide credentials while collectors retain configured access.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_id = "plugin.private_keys" + settings = { + "password": "password-value", + "nested": {"refresh_token": "token-value", "cookie": "cookie-value"}, + "private_key": "private-value", + "ordinary": "safe", + } + (root / "logicytics.json").write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + configuration = load_config(root) + self.assertEqual(settings, configuration.settings_for(collector_id)) + snapshot = configuration.to_manifest_dict() + manifest_settings = cast(dict[str, Any], snapshot["collector_settings"][collector_id]) + self.assertEqual("[REDACTED]", manifest_settings["password"]) + self.assertEqual("[REDACTED]", manifest_settings["nested"]["refresh_token"]) + self.assertEqual("[REDACTED]", manifest_settings["nested"]["cookie"]) + self.assertEqual("[REDACTED]", manifest_settings["private_key"]) + self.assertEqual("safe", manifest_settings["ordinary"]) + for secret in ("password-value", "token-value", "cookie-value", "private-value"): + self.assertNotIn(secret, json.dumps(snapshot)) + self.assertEqual(settings, configuration.settings_for(collector_id)) + + def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> None: + """Runtime worker limits and output locations must retain strict JSON types.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid_configurations = ( + ('{"schema_version":true}', "schema_version"), + ('{"schema_version":4,"runtime":{"default_max_workers":true}}', "worker limits"), + ('{"schema_version":4,"runtime":{"maximum_workers":true}}', "worker limits"), + ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":true}}', "maximum_run_output_bytes"), + ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":0}}', "maximum_run_output_bytes"), + ('{"schema_version":4,"runtime":{"output_root":false}}', "output_root"), + ('{"schema_version":4,"runtime":{"output_root":" "}}', "output_root"), + ) + for payload, message in invalid_configurations: + with self.subTest(payload=payload): + config_path.write_text(payload, encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + load_config(root) + + def test_configuration_rejects_unknown_duplicate_and_non_finite_values(self) -> None: + """Ambiguous keys, typos, malformed IDs, and nonstandard JSON fail before planning.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid = ( + ('{"schema_version":4,"unexpected":true}', "unsupported root"), + ('{"schema_version":4,"runtime":{"worker_typo":2}}', "unsupported settings"), + ('{"schema_version":4,"schema_version":4}', "duplicate configuration key"), + ('{"schema_version":4,"runtime":{"maximum_workers":NaN}}', "non-finite"), + ('{"schema_version":4,"collectors":{"plugin.custom":{"value":1e999}}}', "non-finite"), + ('{"schema_version":4,"collectors":{"../escape":{}}}', "invalid collector ID"), + ('{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', + "invalid setting name"), + ('{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', + "unsupported settings"), + ) + for payload, message in invalid: + with self.subTest(payload=payload): + config_path.write_text(payload, encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + config_path.write_text( + '{"schema_version":4,"collectors":{"plugin.custom":{"extension_setting":"allowed"}}}', + encoding="utf-8", + ) + self.assertEqual("allowed", load_config(root).settings_for("plugin.custom")["extension_setting"]) + + def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) -> None: + """Traversal and sensitive inventory limits cannot silently coerce or expand in workers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + invalid = ( + ("core.filesystem.system_drive_tree", {"max_entries": True}, "max_entries"), + ("core.filesystem.system_drive_tree", {"max_depth": 33}, "max_depth"), + ("core.filesystem.system_drive_listing", {"workers": 2}, "unsupported settings"), + ("core.filesystem.system_drive_listing", {"max_entries": "100"}, "max_entries"), + ("core.filesystem.sensitive_file_inventory", {"max_directories": 50_001}, "max_directories"), + ("core.filesystem.sensitive_file_inventory", {"max_matches": 0}, "max_matches"), + ("core.filesystem.sensitive_file_inventory", {"root": "relative/path"}, "absolute"), + ) + for collector_id, settings, message in invalid: + with self.subTest(collector_id=collector_id, settings=settings): + config_path.write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + valid = { + "core.filesystem.system_drive_tree": {"max_entries": 100, "max_depth": 3}, + "core.filesystem.system_drive_listing": {"max_entries": 200, "max_depth": 4}, + "core.filesystem.sensitive_file_inventory": { + "root": str(root), "max_directories": 100, "max_matches": 10, + }, + } + config_path.write_text(json.dumps({"schema_version": 4, "collectors": valid}), encoding="utf-8") + configuration = load_config(root) + for collector_id, settings in valid.items(): + self.assertEqual(settings, configuration.settings_for(collector_id)) + + def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_paths(self) -> None: + """The existing metadata-only memory mapper rejects unsafe values before worker launch.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + collector_id = "core.process.memory_map" + invalid = ( + ({"max_regions": True}, "max_regions"), + ({"max_regions": 100_001}, "max_regions"), + ({"output_limit_bytes": 1_023}, "output_limit_bytes"), + ({"output_limit_bytes": 64 * 1024 * 1024 + 1}, "output_limit_bytes"), + ({"disk_safety_margin_bytes": -1}, "disk_safety_margin_bytes"), + ({"disk_safety_margin_bytes": "100"}, "disk_safety_margin_bytes"), + ({"dump_directory": "../outside"}, "collector-workspace"), + ({"dump_directory": str(root)}, "collector-workspace"), + ({"dump_directory": " "}, "non-empty"), + ) + for settings, message in invalid: + with self.subTest(settings=settings): + config_path.write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, message): + load_config(root) + self.assertFalse((root / "output").exists()) + settings = { + "max_regions": 500, + "output_limit_bytes": 4096, + "disk_safety_margin_bytes": 0, + "dump_directory": "bounded/maps", + } + config_path.write_text( + json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), + encoding="utf-8", + ) + self.assertEqual(settings, load_config(root).settings_for(collector_id)) + + def test_configuration_profile_collector_and_request_boundaries_are_isolated(self) -> None: + """Product, profile, collector, and invocation settings remain separate contracts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_id = "core.process.memory_map" + collector_settings = { + "output_limit_bytes": 8192, + "disk_safety_margin_bytes": 4096, + "dump_directory": "maps", + } + (root / "logicytics.json").write_text( + json.dumps({ + "schema_version": 4, + "runtime": {"default_max_workers": 3, "maximum_workers": 8}, + "collectors": {collector_id: collector_settings}, + }), + encoding="utf-8", + ) + configuration = load_config(root) + request = RunRequest( + profile="deep", + include=(collector_id,), + max_workers=1, + acknowledge_authorization=True, + ) + + self.assertEqual(3, configuration.runtime.default_max_workers) + self.assertEqual(collector_settings, configuration.settings_for(collector_id)) + self.assertEqual("deep", request.profile) + self.assertEqual((collector_id,), request.include) + self.assertEqual(1, request.max_workers) + self.assertNotIn("profile", configuration.to_manifest_dict()["runtime"]) + self.assertNotIn("max_workers", configuration.to_manifest_dict()["runtime"]) + + def test_configuration_validates_bounded_network_and_packet_settings(self) -> None: + """Collector-specific settings fail early rather than being silently coerced at runtime.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"collectors":{"core.network.bandwidth_sample":' + '{"sample_count":11}}}', + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "sample_count"): + load_config(root) + config_path.write_text( + '{"schema_version":4,"collectors":{"core.packet.packet_capture":' + '{"packet_count":10,"timeout_seconds":5,"retry_window_seconds":2,"interface":"127.0.0.1"}}}', + encoding="utf-8", + ) + settings = load_config(root).settings_for("core.packet.packet_capture") + self.assertEqual(10, settings["packet_count"]) + self.assertEqual(2, settings["retry_window_seconds"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 00000000..374222d0 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import unittest + +from logicytics.contracts import ( + Artifact, + CollectorMetadata, + CollectorResult, + CollectorStatus, + EvidenceKind, + ResourceClass, + Specialty, +) + + +class ContractTests(unittest.TestCase): + """Typed metadata, result, specialty, and artifact contracts.""" + + def test_custom_specialty_is_plugin_only(self) -> None: + """Plugins may extend specialties, but core metadata remains on the closed set.""" + metadata = CollectorMetadata( + id="plugin.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, + description="Test metadata.", author="Test", + ).to_dict() + metadata["specialty"] = "evidence_graph" + self.assertEqual("evidence_graph", CollectorMetadata.from_dict(metadata, allow_custom_specialty=True).specialty) + with self.assertRaises(ValueError): + CollectorMetadata.from_dict(metadata) + + def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: + """Collector metadata must be a complete typed declaration rather than free text.""" + common = dict( + id="core.system.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, + description="Example collector.", author="tests", + ) + with self.assertRaisesRegex(ValueError, "id has an invalid schema"): + CollectorMetadata(**{**common, "id": "example"}) + with self.assertRaisesRegex(ValueError, "semantic versioning"): + CollectorMetadata(**{**common, "version": "four"}) + with self.assertRaisesRegex(ValueError, "maximum_artifact_files"): + CollectorMetadata(**{**common, "maximum_artifact_files": 0}) + with self.assertRaisesRegex(ValueError, "maximum_memory_bytes"): + CollectorMetadata(**{**common, "maximum_memory_bytes": True}) + with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): + CollectorMetadata(**{**common, "maximum_artifact_bytes": True}) + with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): + CollectorMetadata(**{**common, "maximum_output_bytes": 8, "maximum_artifact_bytes": 9}) + with self.assertRaisesRegex(ValueError, "resource_class"): + CollectorMetadata(**{**common, "resource_class": "disk_heavy"}) + with self.assertRaisesRegex(ValueError, "ResourceClass"): + CollectorMetadata.from_dict({**CollectorMetadata(**common).to_dict(), "resource_class": "unknown"}) + with self.assertRaisesRegex(ValueError, "sensitive collectors"): + CollectorMetadata(**{**common, "sensitive_data_categories": ("credentials",)}) + for retries in (-1, 4, True): + with self.subTest(maximum_retries=retries): + with self.assertRaisesRegex(ValueError, "maximum_retries"): + CollectorMetadata(**{**common, "maximum_retries": retries}) + for retry_delay in (-1, 31, True, float("inf")): + with self.subTest(retry_delay_seconds=retry_delay): + with self.assertRaisesRegex(ValueError, "retry_delay_seconds"): + CollectorMetadata(**{**common, "retry_delay_seconds": retry_delay}) + metadata = CollectorMetadata(**{**common, "maximum_output_bytes": 8}) + self.assertEqual(8, metadata.maximum_artifact_bytes) + self.assertIs(ResourceClass.GENERAL, metadata.resource_class) + disk_metadata = CollectorMetadata(**{**common, "resource_class": ResourceClass.DISK_HEAVY}) + self.assertEqual("disk_heavy", disk_metadata.to_dict()["resource_class"]) + self.assertIs(ResourceClass.DISK_HEAVY, CollectorMetadata.from_dict(disk_metadata.to_dict()).resource_class) + + def test_collector_results_expose_all_strict_typed_terminal_states(self) -> None: + """Every terminal collector outcome has an explicit, validated result constructor.""" + outcomes = ( + (CollectorResult.succeeded("complete"), CollectorStatus.SUCCEEDED), + (CollectorResult.partial("incomplete", errors=("one source unavailable",)), CollectorStatus.PARTIAL), + (CollectorResult.skipped("prerequisite unavailable"), CollectorStatus.SKIPPED), + (CollectorResult.cancelled("operator cancelled"), CollectorStatus.CANCELLED), + (CollectorResult.failed("collection failed", errors=("access denied",)), CollectorStatus.FAILED), + ) + for result, status in outcomes: + with self.subTest(status=status): + self.assertIs(status, result.status) + invalid = ( + ({"status": "succeeded", "summary": "complete"}, "status"), + ({"status": CollectorStatus.SUCCEEDED, "summary": ""}, "summary"), + ({"status": CollectorStatus.SUCCEEDED, "summary": "complete", "artifacts": []}, "artifacts"), + ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ["failure"]}, "errors"), + ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ("",)}, "errors"), + ({"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": True}}, "metrics"), + ( + {"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": float("inf")}}, + "metrics", + ), + ) + for options, message in invalid: + with self.subTest(options=options): + with self.assertRaisesRegex(ValueError, message): + CollectorResult(**options) + + def test_artifact_contract_rejects_malformed_catalog_metadata(self) -> None: + """Evidence records validate identity, MIME type, provenance, timestamps, and status.""" + valid = { + "id": "artifact." + "a" * 32, + "relative_path": "core_system_example/report.json", + "sha256": "b" * 64, + "size_bytes": 2, + "media_type": "application/json", + "collector_id": "core.system.example", + "source_category": "system", + "collected_at": "2026-01-01T00:00:00+00:00", + "transformations": ("normalized",), + "evidence_kind": EvidenceKind.DERIVED, + "name": "report.json", + "status": "registered", + } + invalid = ( + ({"id": "artifact.invalid"}, "id"), + ({"sha256": "bad"}, "sha256"), + ({"size_bytes": True}, "size_bytes"), + ({"size_bytes": -1}, "size_bytes"), + ({"media_type": "not a mime type"}, "media_type"), + ({"collector_id": "another"}, "collector_id"), + ({"source_category": "System"}, "source_category"), + ({"collected_at": "2026-01-01T00:00:00"}, "timezone"), + ({"transformations": ["normalized"]}, "transformations"), + ({"evidence_kind": "derived"}, "evidence_kind"), + ({"name": "../report.json"}, "name"), + ({"status": "failed"}, "status"), + ) + self.assertEqual("report.json", Artifact(**valid).name) + for changes, message in invalid: + with self.subTest(changes=changes): + with self.assertRaisesRegex(ValueError, message): + Artifact(**{**valid, **changes}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_core_functionality.py b/tests/test_core_functionality.py deleted file mode 100644 index c841703d..00000000 --- a/tests/test_core_functionality.py +++ /dev/null @@ -1,5696 +0,0 @@ -"""Integration-style checks for the v4 core without shipped collectors.""" -# TODO Dedupe the logic, as its split weirdly - -from __future__ import annotations - -import ctypes -import hashlib -import io -import json -import os -import shutil -import subprocess -import sys -import tempfile -import unittest -import zipfile -from concurrent.futures import ThreadPoolExecutor -from dataclasses import FrozenInstanceError, replace -from datetime import datetime -from pathlib import Path -from typing import Any, Callable, cast -from unittest.mock import MagicMock, patch - -from logicytics import ( - load_configuration, - open_artifact, - plan_run, - query_run, - read_artifact, - run_collection, - maintenance, discovery, -) -from logicytics import packaging -from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.cli import cli_methods, main, CLI -from logicytics.command_runner import parse_level_messages, run_command -from logicytics.configuration import ( - LoggingSettings, - MaintenanceSettings, - default_config, - load_config, -) -from logicytics.contracts import ( - Artifact, - Capability, - CollectorMetadata, - CollectorResult, - CollectorStatus, - EvidenceKind, - OutputPolicy, - PostRunAction, - ResourceClass, - RunRequest, - RunStatus, - Specialty, -) -from logicytics.discovery import PreflightReport, preflight -from logicytics.environment import EnvironmentReport -from logicytics.errors import ArtifactError, PlanError, PreflightError -from logicytics.file_listing import list_files -from logicytics.interaction import load_history, match_flag, usage_statistics -from logicytics.logging import ( - ApplicationLogger, - FileEventLogger, - deprecated, - get_application_logger, - get_event_logger, - raise_logged, - timed, -) -from logicytics.maintenance import ( - build_manifest, - compare_files, - compare_versions, - fetch_remote_manifest, - project_files, - write_local_manifest, -) -from logicytics.manifest import write_manifest -from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix -from logicytics.output_layout import ensure_output_layout -from logicytics.packaging import package_run -from logicytics.planner import BUILTIN_PROFILES, build_plan -from logicytics.platform_adapters import ProcessAdapter, process_adapter -from logicytics.runtime import RunSupervisor -from logicytics.sysinternals import ensure_sysinternals - -_COLLECTOR = '''"""Create a harmless test artifact.""" - -from pathlib import Path - -from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics import EstimatedCost, NetworkAccess, PrivilegeLevel -from logicytics.contracts import CollectorContext - - -class SystemInfoCollector(CoreCollector): - """A valid test core collector.""" - - @classmethod - def metadata(cls) -> CollectorMetadata: - """Return test metadata.""" - return CollectorMetadata( - id="core.system.system_info", - name="System info", - version="1.0.0", - specialty=Specialty.SYSTEM, - output_media_types=("text/plain",), - description="Creates a harmless text artifact for core tests.", - author="tests", - supported_platforms=("win32",), - ) - - def validate(self, context: CollectorContext) -> ValidationResult: - """Validate the test collector.""" - return ValidationResult(True) - - def collect(self, context: CollectorContext) -> CollectorResult: - """Write and register a harmless artifact.""" - output = context.workspace / "system.txt" - output.write_text("ok\\n", encoding="utf-8") - artifact = context.artifacts.register_file(output, media_type="text/plain") - return CollectorResult.succeeded("test artifact created", (artifact,)) - - def cleanup(self, context: CollectorContext) -> None: - """Release test resources.""" -''' - - -def _plugin_collector_source() -> str: - """Convert the core fixture into a plugin with every security field explicit.""" - return _COLLECTOR.replace("CoreCollector", "PluginCollector").replace( - ' author="tests",', - ' author="tests",\n' - ' capabilities=(),\n' - ' privilege_level=PrivilegeLevel.STANDARD,\n' - ' sensitive_data_categories=(),\n' - ' network_access=NetworkAccess.NONE,\n' - ' estimated_cost=EstimatedCost.LOW,\n' - ' timeout_seconds=60,\n' - ' maximum_output_bytes=100 * 1024 * 1024,\n' - ' minimum_contract_version="4.0",', - ) - - -def _mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, Any]: - """Return a complete sidecar declaration for a harmless legacy script fixture.""" - return { - "id": f"mod.{name}", - "name": f"{name} mod", - "version": "1.0.0", - "specialty": "integration", - "description": "Harmless isolated legacy script fixture.", - "author": "tests", - "supported_platforms": [sys.platform], - "capabilities": [ - "subprocess", - *(["filesystem_write"] if filesystem_write else []), - ], - "privilege_level": "standard", - "sensitive_data_categories": [], - "network_access": "none", - "estimated_cost": "low", - "timeout_seconds": 15, - "maximum_output_bytes": 1024 * 1024, - "maximum_artifact_files": 10, - "output_media_types": ["text/plain"], - "minimum_contract_version": "4.0", - "default_profiles": ["standard"], - } - - -def _delayed_collector_source( - filename: str, - delay: float, - *, - parallel_safe: bool = True, - dependencies: tuple[str, ...] = (), - resource_class: ResourceClass = ResourceClass.GENERAL, - fail: bool = False, -) -> str: - """Create a valid fixture collector with observable scheduling duration.""" - class_name = "".join(part.title() for part in filename.split("_")) - source = _COLLECTOR.replace("SystemInfoCollector", f"{class_name}Collector") - source = source.replace("core.system.system_info", f"core.system.{filename}") - source = source.replace("from pathlib import Path\n", "from pathlib import Path\nfrom time import sleep\n") - source = source.replace( - "from logicytics import CollectorMetadata,", - "from logicytics import CollectorMetadata, ResourceClass,", - ) - source = source.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - f' dependencies={dependencies!r},\n' - f' resource_class=ResourceClass.{resource_class.name},\n' - f' parallel_safe={parallel_safe!r},', - ) - source = source.replace( - " def validate(self, context: CollectorContext) -> ValidationResult:\n", - " @classmethod\n" - " def dependencies(cls) -> tuple[str, ...]:\n" - " \"\"\"Return fixture dependencies for scheduler tests.\"\"\"\n" - f" return {dependencies!r}\n\n" - " def validate(self, context: CollectorContext) -> ValidationResult:\n", - ) - source = source.replace( - ' output = context.workspace / "system.txt"', - f' sleep({delay})\n output = context.workspace / "system.txt"', - ) - if fail: - source = source.replace( - f' sleep({delay})\n output = context.workspace / "system.txt"', - f' sleep({delay})\n raise RuntimeError("dependency fixture failed")', - ) - return source - - -class CoreFunctionalityTests(unittest.TestCase): - def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: - """The application sink is typed, redacted, reusable, colored, and size bounded.""" - - class TerminalBuffer(io.StringIO): - def isatty(self) -> bool: - return True - - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - path = root / "output" / "logs" / "Logicytics.log" - path.parent.mkdir(parents=True) - path.write_text("previous secret\n", encoding="utf-8") - old = path.parent / "Logicytics-old.log" - old.write_text("expired\n", encoding="utf-8") - os.utime(old, (0, 0)) - console = TerminalBuffer() - settings = LoggingSettings( - level="DEBUG", - console_enabled=True, - color_enabled=True, - maximum_bytes=1024, - delete_previous=True, - retention_days=1, - ) - logger = ApplicationLogger(path, settings, console=console) - for level in ( - "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION" - ): - logger.event(level, "typed event", password="hidden", sequence=1) - logger.dispatch(("WARNING: parsed warning", "plain batch row")) - logger.raw("raw access_token=hidden") - logger.separator() - for index in range(40): - logger.event("INFO", "bounded row " + str(index) + " " + "x" * 80) - - contents = path.read_text(encoding="utf-8") - self.assertNotIn("previous secret", contents) - self.assertNotIn("hidden", contents) - self.assertLessEqual(path.stat().st_size, settings.maximum_bytes) - self.assertFalse(old.exists()) - self.assertIn("\033[", console.getvalue()) - self.assertIn("[EXCEPTION]", console.getvalue()) - with self.assertRaisesRegex(ValueError, "unsupported log level"): - logger.event("TRACE", "unsupported") - with self.assertRaisesRegex(ValueError, "raw log end"): - logger.raw("bad", end="\r\n") - - shared = get_application_logger(path, LoggingSettings(console_enabled=False)) - self.assertIs( - shared, - get_application_logger(path, LoggingSettings(console_enabled=False)), - ) - event_path = root / "output" / "data" / "run-test" / "logs" / "engine.jsonl" - engine = get_event_logger(event_path, run_id="run-test") - self.assertIs(engine, get_event_logger(event_path, run_id="run-test")) - collector = get_event_logger( - event_path.parent / "collector.jsonl", - run_id="run-test", - collector_id="core.system.system_info", - ) - self.assertIs( - collector, - get_event_logger( - event_path.parent / "collector.jsonl", - run_id="run-test", - collector_id="core.system.system_info", - ), - ) - self.assertIsNot(engine, collector) - - def test_output_layout_and_logging_configuration_are_complete_and_validated(self) -> None: - """Every global output directory and logging policy value has one typed source.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - layout = ensure_output_layout(root / "output" / "data") - for directory in ( - layout.data, - layout.logs, - layout.debug_logs, - layout.performance_logs, - layout.packages, - layout.hashes, - ): - self.assertTrue(directory.is_dir()) - config_path = root / "logicytics.json" - config_path.write_text( - json.dumps({ - "schema_version": 4, - "logging": { - "level": "debug", - "console_enabled": False, - "color_enabled": False, - "file_enabled": True, - "maximum_bytes": 2048, - "delete_previous": True, - "retention_days": 7, - }, - }), - encoding="utf-8", - ) - configuration = load_config(root, config_path) - self.assertEqual("DEBUG", configuration.logging.level) - self.assertEqual(2048, configuration.logging.maximum_bytes) - self.assertEqual(7, configuration.logging.retention_days) - - for invalid_logging in ( - {"level": "TRACE"}, - {"maximum_bytes": True}, - {"retention_days": -1}, - {"console_enabled": 1}, - {"unknown": True}, - ): - config_path.write_text( - json.dumps({"schema_version": 4, "logging": invalid_logging}), - encoding="utf-8", - ) - with self.subTest(logging=invalid_logging), self.assertRaises(PlanError): - load_config(root, config_path) - - def test_maintenance_configuration_requires_pinned_https_and_python_order( - self, - ) -> None: - """Integrity endpoints and Python policy fail closed during configuration loading.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config = root / "logicytics.json" - valid = { - "schema_version": 4, - "maintenance": { - "remote_manifest_url": "https://example.invalid/project.manifest.json", - "remote_manifest_sha256": "a" * 64, - "local_manifest_path": "integrity/project.json", - "minimum_python": "3.11", - "recommended_python": "3.12", - }, - } - config.write_text(json.dumps(valid), encoding="utf-8") - loaded = load_config(root, config) - self.assertEqual( - Path("integrity/project.json"), loaded.maintenance.local_manifest_path - ) - self.assertEqual("3.12", loaded.maintenance.recommended_python) - - for mutation in ( - {"remote_manifest_url": "http://example.invalid/project.json"}, - {"remote_manifest_sha256": None}, - {"local_manifest_path": "../escape.json"}, - {"minimum_python": "3.12", "recommended_python": "3.11"}, - ): - invalid = json.loads(json.dumps(valid)) - invalid["maintenance"].update(mutation) - config.write_text(json.dumps(invalid), encoding="utf-8") - with self.subTest(mutation=mutation), self.assertRaises(PlanError): - load_config(root, config) - - def test_authenticated_remote_manifest_is_strict_data_only_configuration( - self, - ) -> None: - """Pinned HTTPS bytes are accepted, while unpinned or execution-bearing data is rejected.""" - payload = json.dumps( - { - "schema_version": 1, - "version": "4.1.0-snapshot.2", - "files": {"logicytics/cli.py": "a" * 64}, - } - ).encode("utf-8") - settings = MaintenanceSettings( - remote_manifest_url="https://example.invalid/project.manifest.json", - remote_manifest_sha256=hashlib.sha256(payload).hexdigest(), - ) - response = MagicMock() - response.__enter__.return_value.read.return_value = payload - with patch.object( - maintenance.urllib.request, - "urlopen", - return_value=response, - ): - manifest = fetch_remote_manifest(settings) - - self.assertIsNotNone(manifest) - self.assertEqual("4.1.0-snapshot.2", manifest.version) - - tampered = MaintenanceSettings( - remote_manifest_url=settings.remote_manifest_url, - remote_manifest_sha256="0" * 64, - ) - with patch.object( - maintenance.urllib.request, - "urlopen", - return_value=response, - ): - with self.assertRaisesRegex(ValueError, "does not match"): - fetch_remote_manifest(tampered) - - execution_payload = json.dumps( - { - "schema_version": 1, - "version": "4.1.0", - "files": {}, - "collectors": ["remote.code"], - } - ).encode("utf-8") - - execution_response = MagicMock() - execution_response.__enter__.return_value.read.return_value = execution_payload - - execution_settings = MaintenanceSettings( - remote_manifest_url=settings.remote_manifest_url, - remote_manifest_sha256=hashlib.sha256(execution_payload).hexdigest(), - ) - - with patch.object( - maintenance.urllib.request, - "urlopen", - return_value=execution_response, - ): - with self.assertRaisesRegex(ValueError, "only schema_version"): - fetch_remote_manifest(execution_settings) - - def test_integrity_manifest_comparison_and_snapshot_version_ordering(self) -> None: - """Developer integrity reports file states and compare snapshots semantically.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - settings = MaintenanceSettings() - (root / "same.txt").write_text("same", encoding="utf-8") - (root / "changed.txt").write_text("before", encoding="utf-8") - (root / "removed.txt").write_text("remove", encoding="utf-8") - manifest = build_manifest(root, settings, "4.1.0-snapshot.2") - write_local_manifest(root, settings, manifest) - (root / "changed.txt").write_text("after", encoding="utf-8") - (root / "removed.txt").unlink() - (root / "added.txt").write_text("add", encoding="utf-8") - comparison = compare_files(root, settings, manifest) - self.assertEqual(["removed.txt"], comparison["missing"]) - self.assertEqual(["changed.txt"], comparison["modified"]) - self.assertEqual(["added.txt"], comparison["extra"]) - self.assertEqual(["same.txt"], comparison["unchanged"]) - self.assertEqual("behind", compare_versions("4.1.0-snapshot.2", "4.1.0")) - self.assertEqual( - "behind", compare_versions("4.1.0-snapshot.2", "4.1.0-snapshot.10") - ) - cache = root / ".pytest_cache" / "ignored.txt" - cache.parent.mkdir() - cache.write_text("ignored", encoding="utf-8") - tool = root / "tools" / "Sysinternals" / "ignored.exe" - tool.parent.mkdir(parents=True) - tool.write_bytes(b"ignored") - names = {path.relative_to(root).as_posix() for path in project_files(root, settings)} - self.assertNotIn(".pytest_cache/ignored.txt", names) - self.assertNotIn("tools/Sysinternals/ignored.exe", names) - - def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> None: - """Side actions remain separate from collection and write only their dedicated artifacts.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "pyproject.toml").write_text( - '[project]\nname = "fixture"\nversion = "4.0.0"\n', - encoding="utf-8", - ) - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - new_callable=io.StringIO, - ): - self.assertEqual( - 0, - main(["dev", "--write-manifest", "--next-version", "4.1.0"]), - ) - - manifest_path = root / "project.manifest.json" - self.assertTrue(manifest_path.is_file()) - manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) - self.assertEqual("4.1.0", manifest_payload["version"]) - - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - new_callable=io.StringIO, - ): - self.assertEqual(0, main(["debug"])) - debug_path = root / "output" / "logs" / "debug" / "debug.json" - debug = json.loads(debug_path.read_text(encoding="utf-8")) - self.assertEqual("4.0.0", debug["maintenance"]["local_version"]) - self.assertIn( - debug["maintenance"]["python_support"]["status"], - {"recommended", "supported", "incompatible"}, - ) - self.assertEqual("missing", debug["sysinternals"]["status"]) - - def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> None: - """Legacy dev mode preserves comments and updates only its version and file list.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "pyproject.toml").write_text( - '[project]\nname = "fixture"\nversion = "4.0.0"\n', - encoding="utf-8", - ) - collector = root / "core" / "system" / "example.py" - collector.parent.mkdir(parents=True) - collector.write_text('"""Example collector module."""\n', encoding="utf-8") - code = root / "CODE" - code.mkdir() - legacy = code / "config.ini" - legacy.write_text( - "# preserve this comment\n" - "[System Settings]\n" - "version = 3.6.0\n" - 'files = "old.py"\n\n' - "[Unrelated]\n" - "value = untouched\n", - encoding="utf-8", - ) - output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - output, - ): - self.assertEqual( - 0, - main(["dev", "--write-manifest", "--next-version", "4.1.0"]), - ) - payload = json.loads(output.getvalue()) - self.assertEqual(str(legacy), payload["manifest_written"]) - self.assertEqual([], payload["checks"]["misplaced_python"]) - updated = legacy.read_text(encoding="utf-8") - self.assertIn("# preserve this comment", updated) - self.assertIn("version = 4.1.0", updated) - self.assertIn("CODE/config.ini", updated) - self.assertIn("core/system/example.py", updated) - self.assertIn("value = untouched", updated) - self.assertNotIn("old.py", updated) - self.assertFalse((root / "project.manifest.json").exists()) - - def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> None: - """Legacy scripts enter the pipeline only through typed metadata and worker isolation.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - script = mods / "example.py" - script.write_text( - "from pathlib import Path\n" - "Path('report.txt').write_text('mod evidence\\n', encoding='utf-8')\n" - "print('INFO: fixture completed')\n", - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("requires metadata sidecar", report.invalid[0].static_errors[0]) - with self.assertRaises(PreflightError): - build_plan(report, RunRequest(enable_mods=True)) - - script.with_suffix(".py.mod.json").write_text( - json.dumps(_mod_metadata("example")), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - self.assertEqual((), build_plan(report, RunRequest()).collectors) - plan = build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=(Capability.SUBPROCESS,), - acknowledge_authorization=True, - max_workers=1, - ), - ) - self.assertEqual(["mod.example"], [item.metadata.id for item in plan.collectors]) - outcome = RunSupervisor(root, default_config(root)).run(plan) - record = outcome.manifest.collectors[0] - self.assertEqual("succeeded", record.status, record.errors) - self.assertEqual(2, len(record.artifacts)) - report_artifact = next(item for item in record.artifacts if item["name"] == "report.txt") - artifact_path = outcome.run_directory / "artifacts" / str(report_artifact["relative_path"]) - self.assertEqual("mod evidence\n", artifact_path.read_text(encoding="utf-8")) - mods_package = Path(str(outcome.manifest.package["mods_path"])) - mods_hash = Path(str(outcome.manifest.package["mods_sha256_path"])) - self.assertTrue(mods_package.name.startswith("mods-run-")) - self.assertTrue(mods_hash.is_file()) - with zipfile.ZipFile(mods_package) as archive: - names = archive.namelist() - self.assertIn("metadata/mods.json", names) - self.assertTrue(any(name.endswith("/report.txt") for name in names)) - self.assertIsNone(archive.testzip()) - - def test_python_mod_cannot_mutate_project_configuration_without_write_approval(self) -> None: - """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configuration_path = root / "logicytics.json" - configuration_path.write_text('{"schema_version":4}\n', encoding="utf-8") - mods = root / "MODS" - mods.mkdir() - scripts = { - "good.py": "from pathlib import Path\nPath('report.txt').write_text('ok', encoding='utf-8')\n", - "malicious.py": ( - "from pathlib import Path\n" - f"Path({str(configuration_path)!r}).write_text('replaced', encoding='utf-8')\n" - ), - } - for filename, source in scripts.items(): - script = mods / filename - script.write_text(source, encoding="utf-8") - script.with_suffix(".py.mod.json").write_text( - json.dumps(_mod_metadata(script.stem)), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(2, len(report.valid), report.invalid) - plan = build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=(Capability.SUBPROCESS,), - acknowledge_authorization=True, - max_workers=2, - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("succeeded", records["mod.good"].status, records["mod.good"].errors) - self.assertEqual("failed", records["mod.malicious"].status) - self.assertTrue( - any("private workspace" in error for error in records["mod.malicious"].errors), - records["mod.malicious"].errors, - ) - self.assertEqual('{"schema_version":4}\n', configuration_path.read_text(encoding="utf-8")) - - def test_native_mod_requires_explicit_filesystem_write_declaration(self) -> None: - """Native child processes are quarantined unless their unconfined write risk is declared.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - script = mods / "native.bat" - script.write_text("@echo off\n", encoding="utf-8") - script.with_suffix(".bat.mod.json").write_text( - json.dumps(_mod_metadata("native")), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("filesystem_write", report.invalid[0].runtime_error or "") - - script.with_suffix(".bat.mod.json").write_text( - json.dumps(_mod_metadata("native", filesystem_write=True)), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - with self.assertRaisesRegex(PlanError, "filesystem_write"): - build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=(Capability.SUBPROCESS,), - ), - ) - - def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers(self) -> None: - """Compatibility modes include all MODS or only non-Python MODS deterministically.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - for name, extension in (("python_mod", ".py"), ("batch_mod", ".bat")): - script = mods / f"{name}{extension}" - script.write_text("pass\n" if extension == ".py" else "@echo off\n", encoding="utf-8") - script.with_suffix(extension + ".mod.json").write_text( - json.dumps(_mod_metadata(name, filesystem_write=extension != ".py")), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(2, len(report.valid), report.invalid) - modded = build_plan(report, RunRequest( - enable_mods=True, - approved_capabilities=(Capability.SUBPROCESS, Capability.FILESYSTEM_WRITE), - )) - nopy = build_plan(report, RunRequest( - enable_mods=True, - non_python_only=True, - approved_capabilities=(Capability.SUBPROCESS, Capability.FILESYSTEM_WRITE), - )) - self.assertEqual(["mod.batch_mod", "mod.python_mod"], [item.metadata.id for item in modded.collectors]) - self.assertEqual(["mod.batch_mod"], [item.metadata.id for item in nopy.collectors]) - parser = cli_methods.parser() - self.assertTrue(cli_methods.request(parser.parse_args(["run", "--modded"]), 2).enable_mods) - nopy_request = cli_methods.request(parser.parse_args(["run", "--nopy"]), 2) - self.assertTrue(nopy_request.enable_mods) - self.assertTrue(nopy_request.non_python_only) - - @unittest.skipUnless(os.name == "nt", "legacy script adapters require Windows") - def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files(self) -> None: - """Every documented non-Python MODS type executes through its explicit adapter.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - scripts = { - "powershell_mod.ps1": "Set-Content -LiteralPath report.txt -Value 'powershell evidence'\n", - "batch_mod.bat": "@echo off\r\necho batch evidence>report.txt\r\n", - } - for filename, contents in scripts.items(): - script = mods / filename - script.write_text(contents, encoding="utf-8") - script.with_suffix(script.suffix + ".mod.json").write_text( - json.dumps(_mod_metadata(script.stem, filesystem_write=True)), - encoding="utf-8", - ) - executable = Path(os.environ.get("WINDIR", r"C:\Windows")) / "System32" / "whoami.exe" - self.assertTrue(executable.is_file()) - copied_executable = mods / "identity_mod.exe" - shutil.copy2(executable, copied_executable) - copied_executable.with_suffix(".exe.mod.json").write_text( - json.dumps(_mod_metadata("identity_mod", filesystem_write=True)), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(3, len(report.valid), report.invalid) - plan = build_plan( - report, - RunRequest( - enable_mods=True, - non_python_only=True, - approved_capabilities=(Capability.SUBPROCESS, Capability.FILESYSTEM_WRITE), - acknowledge_authorization=True, - max_workers=1, - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual( - ["succeeded", "succeeded", "succeeded"], - [record.status for record in outcome.manifest.collectors], - [record.errors for record in outcome.manifest.collectors], - ) - - def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(self) -> None: - """Natural-language actions use configured matching and persist only with consent.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - output, - ): - self.assertEqual(0, main(["--match", "run a quick basic collection"])) - payload = json.loads(output.getvalue()) - self.assertEqual("minimal", payload["matched_flag"]) - self.assertFalse(payload["history_persisted"]) - self.assertFalse((root / "output" / "data" / "interaction_history.json.gz").exists()) - - (root / "logicytics.json").write_text( - json.dumps({ - "schema_version": 4, - "interaction": { - "history_enabled": True, - "similarity_threshold": 0.5, - "model_name": "stdlib-test-model", - "model_debug": True, - }, - }), - encoding="utf-8", - ) - output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - output, - ): - self.assertEqual(0, main(["--match", "an exhaustive slow scan"])) - payload = json.loads(output.getvalue()) - self.assertEqual("depth", payload["matched_flag"]) - self.assertEqual("stdlib-test-model", payload["model_debug"]["model_name"]) - history_path = root / "output" / "data" / "interaction_history.json.gz" - history = load_history(history_path) - self.assertEqual(1, len(history)) - self.assertIn("timestamp", history[0]) - self.assertIn("device_name", history[0]) - - output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - output, - ): - self.assertEqual(0, main(["--usage"])) - usage = json.loads(output.getvalue()) - self.assertEqual(1, usage["total_interactions"]) - self.assertEqual(1, usage["per_flag_frequency"]["depth"]) - graph_path = Path(usage["graph_path"]) - self.assertTrue(graph_path.exists()) - self.assertIn(" None: - """A weak direct match may reuse a stronger local historical association.""" - history = [{"input": "collect the strange moon report", "matched_flag": "debug"}] - result = match_flag( - "collect strange moon report", - threshold=0.99, - model_name="stdlib-sequence-matcher", - history=history, - ) - self.assertEqual("debug", result.matched_flag) - self.assertEqual("history", result.source) - statistics = usage_statistics([{**history[0], "accuracy": 0.9, "device_name": "host"}]) - self.assertEqual("collect the strange moon report", statistics["common_input"]) - - def test_interaction_configuration_rejects_unsafe_or_ambiguous_values(self) -> None: - """Matching and persistence policy must be typed before any history is opened.""" - invalid_sections = ( - {"similarity_threshold": 1.1}, - {"history_enabled": "yes"}, - {"model_debug": 1}, - {"model_name": "bad\nname"}, - {"unknown": True}, - ) - for section in invalid_sections: - with self.subTest(section=section), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "logicytics.json").write_text( - json.dumps({"schema_version": 4, "interaction": section}), - encoding="utf-8", - ) - with self.assertRaises(PlanError): - load_config(root) - - def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(self) -> None: - """A plugin cannot silently inherit fields that affect consent or scheduling.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - plugin_path = root / "plugins" / "example_plugin.py" - plugin_path.parent.mkdir(parents=True) - source = _plugin_collector_source().replace("SystemInfoCollector", "ExamplePluginCollector") - source = source.replace("core.system.system_info", "plugin.example_plugin") - plugin_path.write_text( - source.replace(" network_access=NetworkAccess.NONE,\n", ""), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn( - "plugin metadata must explicitly declare: network_access", - report.invalid[0].static_errors, - ) - - def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( - self, - ) -> None: - """Power actions remain explicit and cannot run before durable package publication.""" - arguments = cli_methods.parser().parse_args([ - "run", - "--shutdown", - "--performance-check", - "--acknowledge-authorization", - ]) - - request = cli_methods.request(arguments, default_workers=4) - - self.assertEqual(PostRunAction.SHUTDOWN, request.post_run_action) - self.assertEqual(OutputPolicy.PACKAGE, request.output_policy) - self.assertEqual(1, request.max_workers) - - with self.assertRaises(SystemExit): - cli_methods.parser().parse_args([ - "run", - "--reboot", - "--shutdown", - ]) - - with self.assertRaisesRegex(ValueError, "require packaged output"): - cli_methods.request( - cli_methods.parser().parse_args([ - "run", - "--reboot", - "--no-package", - ]), - default_workers=1, - ) - - with tempfile.TemporaryDirectory() as temporary: - logger = FileEventLogger( - Path(temporary) / "events.jsonl", - run_id="run-" + "a" * 32, - ) - - manifest = type( - "Manifest", - (), - { - "status": RunStatus.SUCCEEDED, - "package": { - "path": "evidence.zip", - "sha256": "a" * 64, - }, - }, - )() - - completed = subprocess.CompletedProcess( - ["shutdown"], - 0, - "", - "", - ) - - with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - return_value=completed, - ) as command: - RunSupervisor._execute_post_run_action( - PostRunAction.REBOOT, - manifest, - logger, - ) - - self.assertEqual("shutdown", command.call_args.args[0][0]) - self.assertEqual("/r", command.call_args.args[0][1]) - self.assertEqual("60", command.call_args.args[0][3]) - - def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity( - self, - ) -> None: - """Only an exact validation context may reuse an isolated runtime probe.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - collector_path.write_text(_COLLECTOR, encoding="utf-8") - - initial = preflight(root, configuration_hash="configuration-a") - self.assertEqual(1, len(initial.valid), initial.invalid) - - with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, - ) as probe: - cached = preflight(root, configuration_hash="configuration-a") - self.assertEqual(1, len(cached.valid), cached.invalid) - probe.assert_not_called() - - with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, - ) as probe: - invalidated = preflight(root, configuration_hash="configuration-b") - self.assertEqual(1, len(invalidated.valid), invalidated.invalid) - self.assertEqual(1, probe.call_count) - - def test_public_package_import_does_not_start_runtime_or_create_files(self) -> None: - """Importing contracts exposes lazy application services without host or disk side effects.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - project_root = Path(__file__).resolve().parents[1] - environment = dict(os.environ) - environment["PYTHONPATH"] = str(project_root) - probe = ( - "import pathlib, sys; " - "before = tuple(pathlib.Path.cwd().iterdir()); " - "import logicytics; " - "assert 'logicytics.runtime' not in sys.modules; " - "assert {'load_configuration', 'plan_run', 'run_collection', 'query_run', 'read_artifact'}" - ".issubset(logicytics.__all__); " - "assert tuple(pathlib.Path.cwd().iterdir()) == before" - ) - result = subprocess.run( - [sys.executable, "-c", probe], - cwd=root, - env=environment, - capture_output=True, - check=False, - text=True, - ) - self.assertEqual(0, result.returncode, result.stderr) - self.assertEqual([], list(root.iterdir())) - - def test_public_planning_is_read_only_bounded_and_keeps_plugins_opt_in(self) -> None: - """The application planner validates configuration and extensions without starting work.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plugin_path = root / "plugins" / "example_plugin.py" - plugin_path.parent.mkdir() - plugin_path.write_text( - _plugin_collector_source() - .replace("SystemInfoCollector", "ExamplePluginCollector") - .replace("core.system.system_info", "plugin.example_plugin"), - encoding="utf-8", - ) - (root / "settings.json").write_text( - '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', - encoding="utf-8", - ) - configuration = load_configuration(root, "settings.json") - standard = plan_run(root, RunRequest(max_workers=1), configuration=configuration) - selected = plan_run( - root, - RunRequest(max_workers=1, include=("plugin.example_plugin",)), - configuration=configuration, - ) - - self.assertEqual(["core.system.system_info"], [item.metadata.id for item in standard.collectors]) - self.assertEqual( - ["core.system.system_info", "plugin.example_plugin"], - [item.metadata.id for item in selected.collectors], - ) - with self.assertRaisesRegex(PlanError, "maximum_workers"): - plan_run(root, RunRequest(max_workers=3), configuration=configuration) - with self.assertRaisesRegex(PlanError, "either configuration or config_path"): - plan_run(root, RunRequest(max_workers=1), configuration=configuration, config_path="settings.json") - plugin_path.write_text('"""Invalid optional plugin."""\n', encoding="utf-8") - quarantined = plan_run(root, RunRequest(max_workers=1), configuration=configuration) - self.assertEqual(["core.system.system_info"], [item.metadata.id for item in quarantined.collectors]) - with self.assertRaises(PreflightError): - plan_run( - root, - RunRequest(max_workers=1, include=("plugin.example_plugin",)), - configuration=configuration, - ) - collector_path.write_text('"""Invalid shipped core collector."""\n', encoding="utf-8") - with self.assertRaises(PreflightError): - plan_run(root, RunRequest(max_workers=1), configuration=configuration) - self.assertFalse(configuration.runtime.output_root.exists()) - - def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> None: - """The complete public lifecycle retains isolation and immutable manifest-backed status.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - configuration = load_configuration(root) - request = RunRequest(max_workers=1, acknowledge_authorization=True) - outcome = run_collection(root, request, configuration=configuration) - snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) - - self.assertEqual(RunStatus.SUCCEEDED, snapshot.status, outcome.manifest.package) - self.assertEqual(outcome.run_directory, snapshot.run_directory) - self.assertEqual(outcome.manifest_path, snapshot.manifest_path) - self.assertEqual(1, - json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"]) - self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) - self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) - collector = snapshot.collectors[0] - self.assertIsNotNone(collector.started_at) - self.assertIsNotNone(collector.finished_at) - self.assertIsNotNone(collector.duration_seconds) - assert collector.duration_seconds is not None - self.assertGreaterEqual(collector.duration_seconds, 0) - self.assertEqual("test artifact created", collector.summary) - self.assertEqual((), collector.errors) - self.assertIsNone(collector.failure) - self.assertEqual(1, len(snapshot.artifacts)) - self.assertEqual( - b"ok" + os.linesep.encode("ascii"), - read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration), - ) - with self.assertRaises(FrozenInstanceError): - snapshot.status = RunStatus.FAILED - with self.assertRaises(FrozenInstanceError): - collector.status = "failed" - - def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration( - self, - ) -> None: - """Callers can inspect redacted lifecycle timing and actionable failure details without parsing manifests.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - - collector_path.write_text( - _COLLECTOR.replace( - ' output = context.workspace / "system.txt"', - ' raise RuntimeError("fixture lifecycle failure")\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - - outcome = run_collection( - root, - RunRequest( - max_workers=1, - acknowledge_authorization=True, - ), - ) - - snapshot = query_run(root, outcome.manifest.run_id) - collector = snapshot.collectors[0] - - self.assertEqual("failed", collector.status) - self.assertIsNotNone(collector.duration_seconds) - - assert collector.duration_seconds is not None - - self.assertGreaterEqual(collector.duration_seconds, 0) - self.assertIn("worker crashed", collector.summary) - self.assertTrue( - any( - "fixture lifecycle failure" in error - for error in collector.errors - ) - ) - self.assertEqual( - "core.system.system_info", - collector.failure.collector_id, - ) - self.assertEqual("collect", collector.failure.operation) - self.assertIn( - "fixture lifecycle failure", - collector.failure.platform_error, - ) - self.assertTrue(collector.failure.remediation) - self.assertTrue(collector.failure.retry_safe) - - collector_path.write_text( - _COLLECTOR, - encoding="utf-8", - ) - - output = io.StringIO() - - with patch.object( - CLI, - "project_root", - return_value=root, - ), patch( - "sys.stdout", - output, - ), patch( - "builtins.input", - return_value="", - ) as final_prompt: - exit_code = main( - [ - "run", - "--default", - "--interactive", - "--acknowledge-authorization", - ] - ) - - self.assertEqual(0, exit_code, output.getvalue()) - final_prompt.assert_called_once_with("Press Enter to exit...") - self.assertIn("Collectors:", output.getvalue()) - self.assertIn( - "core.system.system_info status=succeeded duration_seconds=", - output.getvalue(), - ) - self.assertIn( - "summary=test artifact created", - output.getvalue(), - ) - - def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links(self) -> None: - """Persisted status lookup cannot leave its configured run or trust a forged manifest.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) - original = outcome.manifest_path.read_text(encoding="utf-8") - - for invalid in ("../outside", "run-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "run-short"): - with self.subTest(run_id=invalid): - with self.assertRaisesRegex(PlanError, "canonical run identifier"): - query_run(root, invalid) - payload = json.loads(original) - payload["run_id"] = "run-" + "0" * 32 - outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, "identity"): - query_run(root, outcome.manifest.run_id) - invalid_manifests = ( - ("manifest_schema_version", 2, "unsupported run manifest schema_version"), - ("manifest_schema_version", True, "unsupported run manifest schema_version"), - ("status", [], "unsupported run status"), - ("resolved_plan", ["core.system.forged"], "resolved plan"), - ("total_artifact_bytes", 999, "total_artifact_bytes"), - ("requested_at", "not-a-timestamp", "requested_at"), - ("finished_at", None, "finished_at"), - ) - for field, value, message in invalid_manifests: - with self.subTest(field=field): - payload = json.loads(original) - payload[field] = value - outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, message): - query_run(root, outcome.manifest.run_id) - payload = json.loads(original) - payload.pop("manifest_schema_version") - outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, "unsupported run manifest schema_version"): - query_run(root, outcome.manifest.run_id) - invalid_collector_fields = ( - ("started_at", "not-a-timestamp", "collector started_at"), - ("finished_at", None, "require finished_at"), - ("duration_seconds", -1, "duration_seconds"), - ("summary", "", "summary"), - ("errors", [""], "errors"), - ( - "failure", - { - "collector_id": "core.system.system_info", - "operation": "collect", - "platform_error": "forged", - "remediation": "forged", - "retry_safe": True, - }, - "must match failed status", - ), - ) - for field, value, message in invalid_collector_fields: - with self.subTest(collector_field=field): - payload = json.loads(original) - payload["collectors"][0][field] = value - outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, message): - query_run(root, outcome.manifest.run_id) - payload = json.loads(original) - payload["collectors"][0]["id"] = "../outside" - payload["resolved_plan"] = ["../outside"] - outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, "collector record"): - query_run(root, outcome.manifest.run_id) - outcome.manifest_path.write_text( - '{"run_id":"' + outcome.manifest.run_id + '","run_id":"duplicate"}', - encoding="utf-8", - ) - with self.assertRaisesRegex(PlanError, "duplicate run manifest key"): - query_run(root, outcome.manifest.run_id) - outcome.manifest_path.write_text(original, encoding="utf-8") - outside = root / "outside-manifest.json" - outside.write_text(original, encoding="utf-8") - outcome.manifest_path.unlink() - outcome.manifest_path.symlink_to(outside) - with self.assertRaisesRegex(PlanError, "escapes its run-owned directory"): - query_run(root, outcome.manifest.run_id) - - def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_access(self) -> None: - """Only exact, bounded, manifest-cataloged bytes from the producing collector may be read.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) - artifact = outcome.manifest.artifact_list()[0] - stored = outcome.run_directory / "artifacts" / artifact.relative_path - original = stored.read_bytes() - manifest_source = outcome.manifest_path.read_text(encoding="utf-8") - - with patch("logicytics.api.os.startfile") as startfile: - self.assertEqual( - stored, - open_artifact(root, outcome.manifest.run_id, artifact.id), - ) - startfile.assert_called_once_with(stored) - - with self.assertRaisesRegex(ArtifactError, "canonical"): - read_artifact(root, outcome.manifest.run_id, "../outside") - with self.assertRaisesRegex(ArtifactError, "not registered"): - read_artifact(root, outcome.manifest.run_id, "artifact." + "0" * 32) - for invalid_limit in (True, 0, 67_108_865): - with self.subTest(maximum_bytes=invalid_limit): - with self.assertRaisesRegex(ArtifactError, "maximum_bytes"): - read_artifact(root, outcome.manifest.run_id, artifact.id, maximum_bytes=invalid_limit) - with self.assertRaisesRegex(ArtifactError, "bounded read limit"): - read_artifact(root, outcome.manifest.run_id, artifact.id, maximum_bytes=1) - stored.write_bytes(b"bad") - with self.assertRaisesRegex(ArtifactError, "SHA-256 verification"): - read_artifact(root, outcome.manifest.run_id, artifact.id) - with self.assertRaisesRegex(ArtifactError, "size"): - open_artifact(root, outcome.manifest.run_id, artifact.id) - stored.write_bytes(original) - payload = json.loads(manifest_source) - payload["artifact_catalog"][0]["producer_status"] = "failed" - outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, "artifact catalog"): - read_artifact(root, outcome.manifest.run_id, artifact.id) - outcome.manifest_path.write_text(manifest_source, encoding="utf-8") - outside = root / "outside-evidence.txt" - outside.write_bytes(original) - stored.unlink() - stored.symlink_to(outside) - with self.assertRaisesRegex(ArtifactError, "collector-owned store"): - read_artifact(root, outcome.manifest.run_id, artifact.id) - stored.unlink() - owner = stored.parent - owner.rmdir() - redirected_owner = root / "redirected-owner" - redirected_owner.mkdir() - (redirected_owner / stored.name).write_bytes(original) - owner.symlink_to(redirected_owner, target_is_directory=True) - with self.assertRaisesRegex(ArtifactError, "collector-owned store"): - read_artifact(root, outcome.manifest.run_id, artifact.id) - - def test_sysinternals_archive_lifecycle_honors_ignore_and_extracts_safely(self) -> None: - """The local bundle must honor opt-out and extract only within its target directory.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / ".ignore-sysinternals").write_text("", encoding="utf-8") - self.assertEqual("ignored", ensure_sysinternals(root).status) - (root / ".ignore-sysinternals").unlink() - with zipfile.ZipFile(root / "SysinternalsSuite.zip", "w") as archive: - archive.writestr("PsInfo.exe", "fixture") - state = ensure_sysinternals(root) - self.assertEqual("extracted", state.status) - self.assertTrue((state.extraction_directory / "PsInfo.exe").is_file()) - - def test_custom_specialty_is_plugin_only(self) -> None: - """Plugins may extend specialties, but core metadata remains on the closed set.""" - metadata = CollectorMetadata( - id="plugin.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, - description="Test metadata.", author="Test", - ).to_dict() - metadata["specialty"] = "evidence_graph" - self.assertEqual("evidence_graph", CollectorMetadata.from_dict(metadata, allow_custom_specialty=True).specialty) - with self.assertRaises(ValueError): - CollectorMetadata.from_dict(metadata) - - def test_preflight_requires_exact_declared_artifact_media_types(self) -> None: - """A collector cannot disguise or omit the output contract used by registration.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - collector_path.write_text( - _COLLECTOR.replace( - 'output_media_types=("text/plain",)', - 'output_media_types=("application/json",)', - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertTrue( - any("include every" in error for error in report.invalid[0].static_errors), - report.invalid[0].static_errors, - ) - - collector_path.write_text( - _COLLECTOR.replace(' output_media_types=("text/plain",),\n', ""), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertTrue( - any("explicitly declare" in error for error in report.invalid[0].static_errors), - report.invalid[0].static_errors, - ) - - def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: - """Collector metadata must be a complete typed declaration rather than free text.""" - common = dict( - id="core.system.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, - description="Example collector.", author="tests", - ) - with self.assertRaisesRegex(ValueError, "id has an invalid schema"): - CollectorMetadata(**{**common, "id": "example"}) - with self.assertRaisesRegex(ValueError, "semantic versioning"): - CollectorMetadata(**{**common, "version": "four"}) - with self.assertRaisesRegex(ValueError, "maximum_artifact_files"): - CollectorMetadata(**{**common, "maximum_artifact_files": 0}) - with self.assertRaisesRegex(ValueError, "maximum_memory_bytes"): - CollectorMetadata(**{**common, "maximum_memory_bytes": True}) - with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): - CollectorMetadata(**{**common, "maximum_artifact_bytes": True}) - with self.assertRaisesRegex(ValueError, "maximum_artifact_bytes"): - CollectorMetadata(**{**common, "maximum_output_bytes": 8, "maximum_artifact_bytes": 9}) - with self.assertRaisesRegex(ValueError, "resource_class"): - CollectorMetadata(**{**common, "resource_class": "disk_heavy"}) - with self.assertRaisesRegex(ValueError, "ResourceClass"): - CollectorMetadata.from_dict({**CollectorMetadata(**common).to_dict(), "resource_class": "unknown"}) - with self.assertRaisesRegex(ValueError, "sensitive collectors"): - CollectorMetadata(**{**common, "sensitive_data_categories": ("credentials",)}) - for retries in (-1, 4, True): - with self.subTest(maximum_retries=retries): - with self.assertRaisesRegex(ValueError, "maximum_retries"): - CollectorMetadata(**{**common, "maximum_retries": retries}) - for retry_delay in (-1, 31, True, float("inf")): - with self.subTest(retry_delay_seconds=retry_delay): - with self.assertRaisesRegex(ValueError, "retry_delay_seconds"): - CollectorMetadata(**{**common, "retry_delay_seconds": retry_delay}) - metadata = CollectorMetadata(**{**common, "maximum_output_bytes": 8}) - self.assertEqual(8, metadata.maximum_artifact_bytes) - self.assertIs(ResourceClass.GENERAL, metadata.resource_class) - disk_metadata = CollectorMetadata(**{**common, "resource_class": ResourceClass.DISK_HEAVY}) - self.assertEqual("disk_heavy", disk_metadata.to_dict()["resource_class"]) - self.assertIs(ResourceClass.DISK_HEAVY, CollectorMetadata.from_dict(disk_metadata.to_dict()).resource_class) - - def test_collector_results_expose_all_strict_typed_terminal_states(self) -> None: - """Every terminal collector outcome has an explicit, validated result constructor.""" - outcomes = ( - (CollectorResult.succeeded("complete"), CollectorStatus.SUCCEEDED), - (CollectorResult.partial("incomplete", errors=("one source unavailable",)), CollectorStatus.PARTIAL), - (CollectorResult.skipped("prerequisite unavailable"), CollectorStatus.SKIPPED), - (CollectorResult.cancelled("operator cancelled"), CollectorStatus.CANCELLED), - (CollectorResult.failed("collection failed", errors=("access denied",)), CollectorStatus.FAILED), - ) - for result, status in outcomes: - with self.subTest(status=status): - self.assertIs(status, result.status) - invalid = ( - ({"status": "succeeded", "summary": "complete"}, "status"), - ({"status": CollectorStatus.SUCCEEDED, "summary": ""}, "summary"), - ({"status": CollectorStatus.SUCCEEDED, "summary": "complete", "artifacts": []}, "artifacts"), - ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ["failure"]}, "errors"), - ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ("",)}, "errors"), - ({"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": True}}, "metrics"), - ( - {"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": float("inf")}}, - "metrics", - ), - ) - for options, message in invalid: - with self.subTest(options=options): - with self.assertRaisesRegex(ValueError, message): - CollectorResult(**options) - - def test_artifact_contract_rejects_malformed_catalog_metadata(self) -> None: - """Evidence records validate identity, MIME type, provenance, timestamps, and status.""" - valid = { - "id": "artifact." + "a" * 32, - "relative_path": "core_system_example/report.json", - "sha256": "b" * 64, - "size_bytes": 2, - "media_type": "application/json", - "collector_id": "core.system.example", - "source_category": "system", - "collected_at": "2026-01-01T00:00:00+00:00", - "transformations": ("normalized",), - "evidence_kind": EvidenceKind.DERIVED, - "name": "report.json", - "status": "registered", - } - invalid = ( - ({"id": "artifact.invalid"}, "id"), - ({"sha256": "bad"}, "sha256"), - ({"size_bytes": True}, "size_bytes"), - ({"size_bytes": -1}, "size_bytes"), - ({"media_type": "not a mime type"}, "media_type"), - ({"collector_id": "another"}, "collector_id"), - ({"source_category": "System"}, "source_category"), - ({"collected_at": "2026-01-01T00:00:00"}, "timezone"), - ({"transformations": ["normalized"]}, "transformations"), - ({"evidence_kind": "derived"}, "evidence_kind"), - ({"name": "../report.json"}, "name"), - ({"status": "failed"}, "status"), - ) - self.assertEqual("report.json", Artifact(**valid).name) - for changes, message in invalid: - with self.subTest(changes=changes): - with self.assertRaisesRegex(ValueError, message): - Artifact(**{**valid, **changes}) - - def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) -> None: - """Default collection excludes sensitive evidence unless explicitly selected.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - (core_directory / "a_standard.py").write_text( - _delayed_collector_source("a_standard", 0.0), - encoding="utf-8", - ) - sensitive_id = "core.system.z_sensitive" - source = _delayed_collector_source("z_sensitive", 0.0).replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.SENSITIVE_FILES,),\n' - ' sensitive_data_categories=("credentials",),\n' - ' default_profiles=("deep",),', - ) - (core_directory / "z_sensitive.py").write_text(source, encoding="utf-8") - report = preflight(root) - self.assertEqual((), report.invalid) - - standard = build_plan(report, RunRequest()) - self.assertEqual(["core.system.a_standard"], [item.metadata.id for item in standard.collectors]) - with self.assertRaisesRegex(PlanError, "unapproved capabilities"): - build_plan(report, RunRequest(include=(sensitive_id,))) - opted_in = build_plan( - report, - RunRequest( - include=(sensitive_id,), - exclude=("core.system.a_standard",), - approved_capabilities=(Capability.SENSITIVE_FILES,), - ), - ) - self.assertEqual([sensitive_id], [item.metadata.id for item in opted_in.collectors]) - deep = build_plan( - report, - RunRequest(profile="deep", approved_capabilities=(Capability.SENSITIVE_FILES,)), - ) - self.assertEqual([sensitive_id], [item.metadata.id for item in deep.collectors]) - - def test_builtin_profiles_select_declared_members_and_reject_unknown_names(self) -> None: - """Only documented profiles resolve collector-declared membership before any run.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - memberships = { - "a_essential": ("minimal", "standard", "deep", "offline"), - "b_standard": ("standard", "deep"), - "z_deep": ("deep",), - } - for filename, profiles in memberships.items(): - source = _delayed_collector_source(filename, 0.0).replace( - ' supported_platforms=("win32",),', - f' supported_platforms=("win32",),\n default_profiles={profiles!r},', - ) - (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") - report = preflight(root) - self.assertEqual((), report.invalid) - expected = { - "minimal": ["core.system.a_essential"], - "standard": ["core.system.a_essential", "core.system.b_standard"], - "deep": ["core.system.a_essential", "core.system.b_standard", "core.system.z_deep"], - "offline": ["core.system.a_essential"], - } - self.assertEqual(set(expected), set(BUILTIN_PROFILES)) - for profile, collector_ids in expected.items(): - with self.subTest(profile=profile): - plan = build_plan(report, RunRequest(profile=profile)) - self.assertEqual(collector_ids, [candidate.metadata.id for candidate in plan.collectors]) - with self.assertRaisesRegex(PlanError, "unknown collection profile"): - build_plan(report, RunRequest(profile="invented")) - with patch("sys.stderr", new_callable=io.StringIO) as errors: - with self.assertRaises(SystemExit): - cli_methods.parser().parse_args(["plan", "--profile", "invented"]) - self.assertIn("invalid choice", errors.getvalue()) - - def test_offline_profile_rejects_network_collectors_even_with_explicit_approval(self) -> None: - """Offline collection remains local-only regardless of include and capability overrides.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - collector_id = "core.system.network_source" - source = _delayed_collector_source("network_source", 0.0).replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.NETWORK,),\n' - ' network_access=NetworkAccess.LOCAL,\n' - ' default_profiles=("standard",),', - ) - (core_directory / "network_source.py").write_text(source, encoding="utf-8") - report = preflight(root) - self.assertEqual((), report.invalid) - standard = build_plan(report, RunRequest(approved_capabilities=(Capability.NETWORK,))) - self.assertEqual([collector_id], [candidate.metadata.id for candidate in standard.collectors]) - self.assertEqual((), build_plan(report, RunRequest(profile="offline")).collectors) - with self.assertRaisesRegex(PlanError, "offline profile prohibits network-capable"): - build_plan( - report, - RunRequest(profile="offline", include=(collector_id,), approved_capabilities=(Capability.NETWORK,)), - ) - - def test_preflight_rejects_unregistered_collector_resource_classes(self) -> None: - """Invalid scheduling metadata blocks shipped collectors before any run starts.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n resource_class="disk_heavy",', - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("resource_class", report.invalid[0].runtime_error or "") - diagnostic = report.invalid[0].diagnostics[0] - self.assertEqual("runtime.contract", diagnostic.rule) - self.assertEqual(str(collector_path), diagnostic.path) - assert diagnostic.line is not None - self.assertGreater(diagnostic.line, 1) - self.assertIn("resource_class", diagnostic.message) - with self.assertRaises(PreflightError): - build_plan(report, RunRequest()) - - def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: - """A shipped collector cannot silently introduce sensitive default evidence.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - collector_path.write_text( - _COLLECTOR.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' sensitive_data_categories=("credentials",),', - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("sensitive collectors", report.invalid[0].runtime_error or "") - with self.assertRaises(PreflightError): - build_plan(report, RunRequest()) - - def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) -> None: - """Collection consent must explain requested evidence before creating a workspace.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - collector_path.write_text( - _COLLECTOR.replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.SENSITIVE_FILES,),\n' - ' sensitive_data_categories=("credentials", "personal_documents"),\n' - ' default_profiles=("deep",),', - ), - encoding="utf-8", - ) - configuration = default_config(root) - plan = build_plan( - preflight(root), - RunRequest(profile="deep", approved_capabilities=(Capability.SENSITIVE_FILES,)), - ) - with self.assertRaises(PermissionError) as rejected: - RunSupervisor(root, configuration).run(plan) - message = str(rejected.exception) - self.assertIn("--acknowledge-authorization", message) - self.assertIn("selected categories: system", message) - self.assertIn("sensitive outputs: credentials, personal_documents", message) - self.assertFalse(configuration.runtime.output_root.exists()) - - def test_deprecation_decorator_logs_removal_context(self) -> None: - """Deprecated functions must preserve behavior while reporting removal context.""" - events: list[tuple[str, str, dict[str, Any]]] = [] - - class Logger: - @staticmethod - def event(level: str, message: str, **fields: Any) -> None: - events.append((level, message, fields)) - - @deprecated(cast(Any, Logger()), removal_version="5.0", reason="replacement exists") - def old() -> str: - return "still works" - - self.assertEqual("still works", old()) - self.assertEqual("function_deprecated", events[0][1]) - self.assertEqual("5.0", events[0][2]["removal_version"]) - - def test_exception_helper_logs_before_raising(self) -> None: - """Exception helpers must preserve the requested exception type and context.""" - events: list[tuple[str, str, dict[str, Any]]] = [] - - class Logger: - @staticmethod - def event(level: str, message: str, **fields: Any) -> None: - events.append((level, message, fields)) - - with self.assertRaises(ValueError): - raise_logged(cast(Any, Logger()), ValueError, "invalid setting", setting="workers") - self.assertEqual("exception", events[0][0]) - self.assertEqual("ValueError", events[0][2]["exception_type"]) - - def test_timed_decorator_records_function_lifecycle(self) -> None: - """Timing instrumentation must report start and finish through EventLogger.""" - events: list[tuple[str, str, dict[str, Any]]] = [] - - class Logger: - @staticmethod - def event(level: str, message: str, **fields: Any) -> None: - events.append((level, message, fields)) - - @timed(cast(Any, Logger())) - def add(left: int, right: int) -> int: - return left + right - - self.assertEqual(3, add(1, 2)) - self.assertEqual(["function_started", "function_finished"], [event[1] for event in events]) - - def test_structured_event_logger_redacts_secret_fields_and_inline_credentials(self) -> None: - """Structured diagnostics must preserve useful fields without exposing secrets.""" - with tempfile.TemporaryDirectory() as temporary: - path = Path(temporary) / "events.jsonl" - logger = FileEventLogger(path, run_id="test-run", collector_id="core.system.private_keys") - logger.event( - "INFO", - "Authorization: Bearer bearer-value password=message-value", - password="field-value", - api_key="api-value", - ordinary="safe", - ) - contents = path.read_text(encoding="utf-8") - for secret in ("bearer-value", "message-value", "field-value", "api-value"): - self.assertNotIn(secret, contents) - event = json.loads(contents) - self.assertEqual("info", event["level"]) - self.assertEqual("core.system.private_keys", event["collector_id"]) - self.assertEqual("[REDACTED]", event["fields"]["password"]) - self.assertEqual("[REDACTED]", event["fields"]["api_key"]) - self.assertEqual("safe", event["fields"]["ordinary"]) - - def test_structured_event_logger_serializes_concurrent_jsonl_events(self) -> None: - """Concurrent diagnostics stay complete, independently parseable, and redacted.""" - with tempfile.TemporaryDirectory() as temporary: - path = Path(temporary) / "events.jsonl" - logger = FileEventLogger(path, run_id="concurrent-run", collector_id="core.system.test") - - def write_event(index: int) -> None: - logger.event("info", "collector_progress", sequence=index, password=f"secret-{index}") - - with ThreadPoolExecutor(max_workers=12) as executor: - list(executor.map(write_event, range(120))) - events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] - - self.assertEqual(120, len(events)) - self.assertEqual(set(range(120)), {event["fields"]["sequence"] for event in events}) - self.assertTrue(all(event["fields"]["password"] == "[REDACTED]" for event in events)) - - def test_file_listing_filters_and_normalizes_files(self) -> None: - """Recursive file discovery must filter extensions and excluded directories.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / "collectors").mkdir() - (root / "collectors" / "valid.py").write_text("", encoding="utf-8") - (root / "collectors" / "ignore.txt").write_text("", encoding="utf-8") - (root / ".venv").mkdir() - (root / ".venv" / "hidden.py").write_text("", encoding="utf-8") - files = list_files(root, extensions=(".py",), excluded_directories=(".venv",)) - self.assertEqual((root / "collectors" / "valid.py",), files) - - def test_command_runner_captures_output_and_parses_structured_levels(self) -> None: - """Core command execution must avoid a shell and preserve structured output.""" - result = run_command(("python", "-c", "print('INFO: collected'); print('ordinary')")) - self.assertEqual(0, result.returncode) - self.assertEqual((("INFO", "collected"),), parse_level_messages(result.stdout)) - - def test_configuration_schema_version_is_enforced(self) -> None: - """Current v4 and explicitly migrated v3 schemas are accepted; other versions are not.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - for unsupported in (0, 1, 2, 5): - with self.subTest(schema_version=unsupported): - config_path.write_text(json.dumps({"schema_version": unsupported}), encoding="utf-8") - with self.assertRaisesRegex(PlanError, "unsupported configuration schema_version"): - load_config(root) - config_path.write_text('{"schema_version": 3}', encoding="utf-8") - migrated = load_config(root) - self.assertEqual(4, migrated.schema_version) - self.assertEqual(3, migrated.migrated_from_schema) - config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") - current = load_config(root) - self.assertEqual(4, current.schema_version) - self.assertIsNone(current.migrated_from_schema) - config_path.write_text('{"schema_version":4,"runtime":{"package_completed_runs":"yes"}}', encoding="utf-8") - with self.assertRaisesRegex(PlanError, "package_completed_runs"): - load_config(root) - - def test_legacy_configuration_migrates_runtime_and_collector_aliases_without_writing(self) -> None: - """A supported v3 configuration migrates once in memory and preserves its source bytes.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - settings = {"core.packet.packet_capture": {"packet_count": 7, "timeout_seconds": 5}} - original = json.dumps({ - "schema_version": 3, - "workers": 3, - "max_workers": 6, - "output_root": "ACCESS/RUNS", - "collector_settings": settings, - }, indent=2) - config_path.write_text(original, encoding="utf-8") - configuration = load_config(root) - - self.assertEqual(4, configuration.schema_version) - self.assertEqual(3, configuration.migrated_from_schema) - self.assertEqual(3, configuration.runtime.default_max_workers) - self.assertEqual(6, configuration.runtime.maximum_workers) - self.assertEqual(root / "output" / "data", configuration.runtime.output_root) - self.assertEqual(settings["core.packet.packet_capture"], - configuration.settings_for("core.packet.packet_capture")) - self.assertEqual(original, config_path.read_text(encoding="utf-8")) - - def test_historical_code_config_ini_migrates_into_typed_v4_settings(self) -> None: - """The original CODE/config.ini is a bounded read-only fallback when JSON is absent.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - code = root / "CODE" - code.mkdir() - config_path = code / "config.ini" - original = """[Settings] -log_using_debug = true -delete_old_logs = true -max_workers = 6 -save_preferences = false - -[Flag Settings] -accuracy_min = 30.0 -model_to_use = all-MiniLM-L6-v2 -model_debug = true - -[DumpMemory Settings] -file_size_limit = 8 -file_size_safety = 1.5 - -[NetWorkPsutil Settings] -sample_count = 5 -interval = 1.5 - -[PacketSniffer Settings] -interface = WiFi -packet_count = 5000 -timeout = 10 -max_retry_time = 30 -""" - config_path.write_text(original, encoding="utf-8") - - configuration = load_config(root) - - self.assertEqual(3, configuration.migrated_from_schema) - self.assertEqual(6, configuration.runtime.default_max_workers) - self.assertEqual(6, configuration.runtime.maximum_workers) - self.assertEqual("DEBUG", configuration.logging.level) - self.assertTrue(configuration.logging.delete_previous) - self.assertFalse(configuration.interaction.history_enabled) - self.assertEqual(0.3, configuration.interaction.similarity_threshold) - self.assertEqual("all-MiniLM-L6-v2", configuration.interaction.model_name) - self.assertTrue(configuration.interaction.model_debug) - self.assertEqual( - { - "output_limit_bytes": 8 * 1024 * 1024, - "disk_safety_margin_bytes": 4 * 1024 * 1024, - "dump_directory": "memory_maps", - }, - configuration.settings_for("core.process.memory_map"), - ) - self.assertEqual( - {"sample_count": 5, "interval_seconds": 1.5}, - configuration.settings_for("core.network.bandwidth_sample"), - ) - self.assertEqual(5000, configuration.settings_for("core.packet.packet_capture")["packet_count"]) - self.assertEqual(original, config_path.read_text(encoding="utf-8")) - - (root / "logicytics.json").write_text('{"schema_version":4}', encoding="utf-8") - self.assertIsNone(load_config(root).migrated_from_schema) - self.assertFalse((root / "output").exists()) - self.assertEqual(3, configuration.to_manifest_dict()["migrated_from_schema"]) - - def test_legacy_configuration_rejects_ambiguous_unsafe_and_plugin_enabling_migrations(self) -> None: - """Migration cannot override settings, weaken validation, or silently enable extensions.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - invalid = ( - ({"workers": 2, "worker_count": 3}, "conflicting settings"), - ({"workers": 2, "runtime": {"default_max_workers": 2}}, "conflicting settings"), - ({"runtime": {"workers": 2, "worker_count": 3}}, "conflicting settings"), - ({"collectors": {}, "collector_settings": {}}, "conflicting collectors"), - ({"enable_plugins": True}, "unsupported root"), - ({"workers": True}, "worker limits"), - ({"collector_settings": {"core.process.memory_map": {"dump_directory": "../outside"}}}, - "collector-workspace"), - ({"collector_settings": {"core.packet.packet_capture": {"packet_count": 0}}}, "packet_count"), - ) - for legacy, message in invalid: - with self.subTest(legacy=legacy): - config_path.write_text(json.dumps({"schema_version": 3, **legacy}), encoding="utf-8") - with self.assertRaisesRegex(PlanError, message): - load_config(root) - self.assertFalse((root / "output").exists()) - - def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evidence(self) -> None: - """Migrated collection stays isolated, packages normally, and never moves old evidence.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - legacy = root / "ACCESS" / "RUNS" / "previous.txt" - legacy.parent.mkdir(parents=True) - legacy.write_text("keep legacy evidence", encoding="utf-8") - config_path = root / "logicytics.json" - original = json.dumps({"schema_version": 3, "worker_count": 1, "output_root": "ACCESS/RUNS"}) - config_path.write_text(original, encoding="utf-8") - configuration = load_config(root) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, configuration).run(plan) - - self.assertEqual(root / "output" / "data", outcome.run_directory.parent) - self.assertEqual(3, outcome.manifest.configuration["migrated_from_schema"]) - self.assertEqual("keep legacy evidence", legacy.read_text(encoding="utf-8")) - self.assertEqual(original, config_path.read_text(encoding="utf-8")) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = json.loads(archive.read("metadata/manifest.json")) - self.assertEqual(4, packaged["configuration"]["schema_version"]) - self.assertEqual(3, packaged["configuration"]["migrated_from_schema"]) - - def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: - """Defaults and loaded settings share output/data while explicit roots remain supported.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - expected = root / "output" / "data" - self.assertEqual(expected, default_config(root).runtime.output_root) - self.assertEqual(expected, load_config(root).runtime.output_root) - config_path = root / "logicytics.json" - config_path.write_text('{"schema_version":4,"runtime":{}}', encoding="utf-8") - self.assertEqual(expected, load_config(root).runtime.output_root) - config_path.write_text( - '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', - encoding="utf-8", - ) - self.assertEqual(root / "custom" / "evidence", load_config(root).runtime.output_root) - self.assertFalse(expected.exists()) - - def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings(self) -> None: - """Manifest snapshots hide credentials while collectors retain configured access.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_id = "plugin.private_keys" - settings = { - "password": "password-value", - "nested": {"refresh_token": "token-value", "cookie": "cookie-value"}, - "private_key": "private-value", - "ordinary": "safe", - } - (root / "logicytics.json").write_text( - json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), - encoding="utf-8", - ) - configuration = load_config(root) - self.assertEqual(settings, configuration.settings_for(collector_id)) - snapshot = cast(dict[str, Any], configuration.to_manifest_dict()) - manifest_settings = cast(dict[str, Any], snapshot["collector_settings"][collector_id]) - self.assertEqual("[REDACTED]", manifest_settings["password"]) - self.assertEqual("[REDACTED]", manifest_settings["nested"]["refresh_token"]) - self.assertEqual("[REDACTED]", manifest_settings["nested"]["cookie"]) - self.assertEqual("[REDACTED]", manifest_settings["private_key"]) - self.assertEqual("safe", manifest_settings["ordinary"]) - for secret in ("password-value", "token-value", "cookie-value", "private-value"): - self.assertNotIn(secret, json.dumps(snapshot)) - self.assertEqual(settings, configuration.settings_for(collector_id)) - - def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> None: - """Runtime worker limits and output locations must retain strict JSON types.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - invalid_configurations = ( - ('{"schema_version":true}', "schema_version"), - ('{"schema_version":4,"runtime":{"default_max_workers":true}}', "worker limits"), - ('{"schema_version":4,"runtime":{"maximum_workers":true}}', "worker limits"), - ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":true}}', "maximum_run_output_bytes"), - ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":0}}', "maximum_run_output_bytes"), - ('{"schema_version":4,"runtime":{"output_root":false}}', "output_root"), - ('{"schema_version":4,"runtime":{"output_root":" "}}', "output_root"), - ) - for payload, message in invalid_configurations: - with self.subTest(payload=payload): - config_path.write_text(payload, encoding="utf-8") - with self.assertRaisesRegex(PlanError, message): - load_config(root) - - def test_configuration_rejects_unknown_duplicate_and_non_finite_values(self) -> None: - """Ambiguous keys, typos, malformed IDs, and nonstandard JSON fail before planning.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - invalid = ( - ('{"schema_version":4,"unexpected":true}', "unsupported root"), - ('{"schema_version":4,"runtime":{"worker_typo":2}}', "unsupported settings"), - ('{"schema_version":4,"schema_version":4}', "duplicate configuration key"), - ('{"schema_version":4,"runtime":{"maximum_workers":NaN}}', "non-finite"), - ('{"schema_version":4,"collectors":{"plugin.custom":{"value":1e999}}}', "non-finite"), - ('{"schema_version":4,"collectors":{"../escape":{}}}', "invalid collector ID"), - ('{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', - "invalid setting name"), - ('{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', - "unsupported settings"), - ) - for payload, message in invalid: - with self.subTest(payload=payload): - config_path.write_text(payload, encoding="utf-8") - with self.assertRaisesRegex(PlanError, message): - load_config(root) - self.assertFalse((root / "output").exists()) - config_path.write_text( - '{"schema_version":4,"collectors":{"plugin.custom":{"extension_setting":"allowed"}}}', - encoding="utf-8", - ) - self.assertEqual("allowed", load_config(root).settings_for("plugin.custom")["extension_setting"]) - - def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) -> None: - """Traversal and sensitive inventory limits cannot silently coerce or expand in workers.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - invalid = ( - ("core.filesystem.system_drive_tree", {"max_entries": True}, "max_entries"), - ("core.filesystem.system_drive_tree", {"max_depth": 33}, "max_depth"), - ("core.filesystem.system_drive_listing", {"workers": 2}, "unsupported settings"), - ("core.filesystem.system_drive_listing", {"max_entries": "100"}, "max_entries"), - ("core.filesystem.sensitive_file_inventory", {"max_directories": 50_001}, "max_directories"), - ("core.filesystem.sensitive_file_inventory", {"max_matches": 0}, "max_matches"), - ("core.filesystem.sensitive_file_inventory", {"root": "relative/path"}, "absolute"), - ) - for collector_id, settings, message in invalid: - with self.subTest(collector_id=collector_id, settings=settings): - config_path.write_text( - json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), - encoding="utf-8", - ) - with self.assertRaisesRegex(PlanError, message): - load_config(root) - self.assertFalse((root / "output").exists()) - valid = { - "core.filesystem.system_drive_tree": {"max_entries": 100, "max_depth": 3}, - "core.filesystem.system_drive_listing": {"max_entries": 200, "max_depth": 4}, - "core.filesystem.sensitive_file_inventory": { - "root": str(root), "max_directories": 100, "max_matches": 10, - }, - } - config_path.write_text(json.dumps({"schema_version": 4, "collectors": valid}), encoding="utf-8") - configuration = load_config(root) - for collector_id, settings in valid.items(): - self.assertEqual(settings, configuration.settings_for(collector_id)) - - def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_paths(self) -> None: - """The existing metadata-only memory mapper rejects unsafe values before worker launch.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - collector_id = "core.process.memory_map" - invalid = ( - ({"max_regions": True}, "max_regions"), - ({"max_regions": 100_001}, "max_regions"), - ({"output_limit_bytes": 1_023}, "output_limit_bytes"), - ({"output_limit_bytes": 64 * 1024 * 1024 + 1}, "output_limit_bytes"), - ({"disk_safety_margin_bytes": -1}, "disk_safety_margin_bytes"), - ({"disk_safety_margin_bytes": "100"}, "disk_safety_margin_bytes"), - ({"dump_directory": "../outside"}, "collector-workspace"), - ({"dump_directory": str(root)}, "collector-workspace"), - ({"dump_directory": " "}, "non-empty"), - ) - for settings, message in invalid: - with self.subTest(settings=settings): - config_path.write_text( - json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), - encoding="utf-8", - ) - with self.assertRaisesRegex(PlanError, message): - load_config(root) - self.assertFalse((root / "output").exists()) - settings = { - "max_regions": 500, - "output_limit_bytes": 4096, - "disk_safety_margin_bytes": 0, - "dump_directory": "bounded/maps", - } - config_path.write_text( - json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), - encoding="utf-8", - ) - self.assertEqual(settings, load_config(root).settings_for(collector_id)) - - def test_configuration_profile_collector_and_request_boundaries_are_isolated(self) -> None: - """Product, profile, collector, and invocation settings remain separate contracts.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_id = "core.process.memory_map" - collector_settings = { - "output_limit_bytes": 8192, - "disk_safety_margin_bytes": 4096, - "dump_directory": "maps", - } - (root / "logicytics.json").write_text( - json.dumps({ - "schema_version": 4, - "runtime": {"default_max_workers": 3, "maximum_workers": 8}, - "collectors": {collector_id: collector_settings}, - }), - encoding="utf-8", - ) - configuration = load_config(root) - request = RunRequest( - profile="deep", - include=(collector_id,), - max_workers=1, - acknowledge_authorization=True, - ) - - self.assertEqual(3, configuration.runtime.default_max_workers) - self.assertEqual(collector_settings, configuration.settings_for(collector_id)) - self.assertEqual("deep", request.profile) - self.assertEqual((collector_id,), request.include) - self.assertEqual(1, request.max_workers) - self.assertNotIn("profile", configuration.to_manifest_dict()["runtime"]) - self.assertNotIn("max_workers", configuration.to_manifest_dict()["runtime"]) - - def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> None: - """Run requests must be immutable, typed declarations before a plan exists.""" - collector_id = "core.system.system_info" - invalid_requests = ( - ({"profile": "Standard"}, "profile"), - ({"include": [collector_id]}, "include"), - ({"include": ("../system",)}, "include"), - ({"include": (collector_id, collector_id)}, "include"), - ({"include": (collector_id,), "exclude": (collector_id,)}, "overlap"), - ({"enable_plugins": 1}, "enable_plugins"), - ({"acknowledge_authorization": 1}, "acknowledge_authorization"), - ({"performance_check": 1}, "performance_check"), - ({"performance_check": True, "max_workers": 2}, "performance_check"), - ({"max_workers": True}, "max_workers"), - ({"max_workers": 65}, "max_workers"), - ({"rerun_from": "../outside", "include": (collector_id,)}, "rerun_from"), - ({"rerun_from": "run-" + "a" * 32}, "explicit included"), - ({"approved_capabilities": ("filesystem_read",)}, "approved_capabilities"), - ( - {"approved_capabilities": (Capability.FILESYSTEM_READ, Capability.FILESYSTEM_READ)}, - "approved_capabilities", - ), - ) - for options, message in invalid_requests: - with self.subTest(options=options): - with self.assertRaisesRegex(ValueError, message): - RunRequest(**options) - - def test_configured_worker_limit_blocks_oversized_run_before_workspace_creation(self) -> None: - """The configured maximum worker count bounds a run before any collector starts.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - config_path = root / "logicytics.json" - config_path.write_text( - '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', - encoding="utf-8", - ) - configuration = load_config(root) - self.assertEqual(1, configuration.runtime.default_max_workers) - self.assertEqual(2, configuration.runtime.maximum_workers) - plan = build_plan(preflight(root), RunRequest(max_workers=3, acknowledge_authorization=True)) - with self.assertRaisesRegex(ValueError, "maximum_workers"): - RunSupervisor(root, configuration).run(plan) - self.assertFalse(configuration.runtime.output_root.exists()) - - def test_configuration_validates_bounded_network_and_packet_settings(self) -> None: - """Collector-specific settings fail early rather than being silently coerced at runtime.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.json" - config_path.write_text( - '{"schema_version":4,"collectors":{"core.network.bandwidth_sample":' - '{"sample_count":11}}}', - encoding="utf-8", - ) - with self.assertRaisesRegex(PlanError, "sample_count"): - load_config(root) - config_path.write_text( - '{"schema_version":4,"collectors":{"core.packet.packet_capture":' - '{"packet_count":10,"timeout_seconds":5,"retry_window_seconds":2,"interface":"127.0.0.1"}}}', - encoding="utf-8", - ) - settings = load_config(root).settings_for("core.packet.packet_capture") - self.assertEqual(10, settings["packet_count"]) - self.assertEqual(2, settings["retry_window_seconds"]) - - def test_run_parser_accepts_performance_check(self) -> None: - """The run command must expose the performance mode used by the request builder.""" - arguments = cli_methods.parser().parse_args(["run", "--performance-check"]) - self.assertTrue(arguments.performance_check) - request = cli_methods.request(arguments, default_workers=4) - self.assertTrue(request.performance_check) - self.assertEqual(1, request.max_workers) - - def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( - self, - ) -> None: - """Direct execution never pulls unrelated profile members into its supervised run.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core = root / "core" / "system" - core.mkdir(parents=True) - (root / "plugins").mkdir() - - (core / "first.py").write_text( - _delayed_collector_source("first", 0), - encoding="utf-8", - ) - (core / "second.py").write_text( - _delayed_collector_source("second", 0), - encoding="utf-8", - ) - - output = io.StringIO() - - with patch.object( - CLI, - "project_root", - return_value=root, - ), patch( - "sys.stdout", - output, - ): - self.assertEqual( - 0, - main([ - "collector", - "core.system.first", - "--acknowledge-authorization", - ]), - ) - - manifests = list( - (root / "output" / "data").glob("run-*/manifest.json") - ) - self.assertEqual(1, len(manifests)) - - manifest = json.loads( - manifests[0].read_text(encoding="utf-8") - ) - - self.assertEqual( - ["core.system.first"], - [record["id"] for record in manifest["collectors"]], - ) - self.assertIn("Status: succeeded", output.getvalue()) - - dependent = core / "dependent.py" - dependent.write_text( - _delayed_collector_source( - "dependent", - 0, - dependencies=("core.system.second",), - ), - encoding="utf-8", - ) - - report = preflight(root) - plan = build_plan( - report, - RunRequest( - include=("core.system.dependent",), - selection_only=True, - acknowledge_authorization=True, - max_workers=1, - ), - ) - - self.assertEqual( - ["core.system.second", "core.system.dependent"], - [candidate.metadata.id for candidate in plan.collectors], - ) - - def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: - """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" - parser = cli_methods.parser() - expected = { - "standard": ("standard", 1, False, False, False), - "balanced": ("standard", 4, False, False, False), - "quick": ("minimal", 4, False, False, False), - "thorough": ("deep", 4, False, False, False), - "offline": ("offline", 4, False, False, False), - "extensions": ("standard", 4, True, False, False), - "non-python": ("standard", 4, True, True, False), - "performance": ("standard", 1, False, False, True), - } - - self.assertEqual(set(expected), set(EXECUTION_MODES)) - - modes = mode_matrix()["modes"] - assert isinstance(modes, list) - assert all(isinstance(item, dict) for item in modes) - - self.assertEqual( - set(expected), - {item["name"] for item in modes}, - ) - - for name, contract in expected.items(): - with self.subTest(mode=name): - run_request = cli_methods.request( - parser.parse_args(["run", "--mode", name]), - 4, - ) - self.assertEqual( - contract, - ( - run_request.profile, - run_request.max_workers, - run_request.enable_mods, - run_request.non_python_only, - run_request.performance_check, - ), - ) - - alias_flags = { - "default_mode": "--default", - "threaded": "--threaded", - "minimal": "--minimal", - "depth": "--depth", - "modded": "--modded", - "nopy": "--nopy", - "performance_check": "--performance-check", - } - - for field, mode_name in LEGACY_MODE_ALIASES.items(): - with self.subTest(alias=alias_flags[field]): - legacy = cli_methods.request( - parser.parse_args(["run", alias_flags[field]]), - 4, - ) - named = cli_methods.request( - parser.parse_args(["run", "--mode", mode_name]), - 4, - ) - self.assertEqual(named, legacy) - - with self.assertRaisesRegex(ValueError, "--profile"): - cli_methods.request( - parser.parse_args([ - "run", - "--mode", - "quick", - "--profile", - "deep", - ]), - 4, - ) - - with patch("sys.stderr"), self.assertRaises(SystemExit): - parser.parse_args(["run", "--mode", "quick", "--minimal"]) - - def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: - """Users and release checks can inspect the same authoritative mode registry.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - output = io.StringIO() - - with patch.object( - CLI, - "project_root", - return_value=root, - ), patch( - "sys.stdout", - output, - ): - self.assertEqual(0, main(["--modes"])) - - payload = json.loads(output.getvalue()) - - self.assertEqual(1, payload["schema_version"]) - self.assertEqual( - list(EXECUTION_MODES), - [item["name"] for item in payload["modes"]], - ) - self.assertTrue( - all("legacy_aliases" in item for item in payload["modes"]) - ) - self.assertEqual([], payload["collectors"]) - - def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: - """An empty invocation is useful while contradictory legacy actions fail immediately.""" - output = io.StringIO() - with patch("sys.stdout", output): - self.assertEqual(0, main([])) - rendered = output.getvalue() - self.assertIn("Logicytics v4 run-oriented evidence framework", rendered) - self.assertIn("preflight", rendered) - self.assertIn("run", rendered) - with patch("sys.stderr", new_callable=io.StringIO) as errors: - with self.assertRaises(SystemExit): - cli_methods.parser().parse_args(["run", "--default", "--performance-check"]) - self.assertIn("not allowed with argument", errors.getvalue()) - - def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( - self, - ) -> None: - """The paired update options launch exactly one shell-free visible Windows action.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - (root / ".git").mkdir() - - git = subprocess.CompletedProcess( - ["git", "--version"], - 0, - "git version 2.0\n", - "", - ) - output = io.StringIO() - - with patch.object( - CLI, - CLI.project_root.__name__, - return_value=root, - ), patch.object( - process_adapter, - process_adapter.run.__name__, - return_value=git, - ), patch.object( - CLI, - CLI.launch_action_window.__name__, - return_value=321, - ) as launch, patch( - "sys.stdout", - output, - ): - self.assertEqual( - 0, - main([ - "update", - "--launch-action", - "debug", - "--new-window", - ]), - ) - - payload = json.loads(output.getvalue()) - self.assertEqual("debug", payload["launched_action"]) - self.assertEqual(321, payload["launched_process_id"]) - launch.assert_called_once_with(root, "debug") - - with patch.object( - CLI, - CLI.project_root.__name__, - return_value=root, - ), patch( - "sys.stdout", - new_callable=io.StringIO, - ) as invalid_output: - self.assertEqual( - 2, - main(["update", "--new-window"]), - ) - - self.assertIn( - "must be provided together", - invalid_output.getvalue(), - ) - - def test_new_window_launcher_uses_current_interpreter_without_a_shell( - self, - ) -> None: - """Visible maintenance windows preserve argument boundaries and repository cwd.""" - root = Path("C:/repo").resolve() - process = MagicMock(pid=42) - - with patch( - "sys.platform", - "win32", - ), patch.object( - process_adapter, - process_adapter.popen.__name__, - return_value=process, - ) as popen: - self.assertEqual( - 42, - cli_methods.launch_action_window(root, "preflight"), - ) - - popen.assert_called_once_with( - [sys.executable, "-m", "logicytics", "preflight"], - cwd=root, - shell=False, - creationflags=process_adapter.create_new_console, - close_fds=True, - ) - - with patch( - "sys.platform", - "linux", - ), self.assertRaisesRegex( - OSError, - "only on Windows", - ): - cli_methods.launch_action_window(root, "debug") - - def test_run_parser_exposes_explicit_sequential_and_bounded_parallel_modes(self) -> None: - """Execution policy is selectable directly instead of relying on compatibility modes.""" - parser = cli_methods.parser() - sequential = cli_methods.request(parser.parse_args(["run", "--sequential"]), default_workers=4) - parallel = cli_methods.request(parser.parse_args(["run", "--parallel"]), default_workers=4) - bounded_parallel = cli_methods.request( - parser.parse_args(["run", "--parallel", "--workers", "3"]), - default_workers=4, - ) - - self.assertEqual(1, sequential.max_workers) - self.assertEqual(4, parallel.max_workers) - self.assertEqual(3, bounded_parallel.max_workers) - self.assertEqual("standard", sequential.profile) - self.assertEqual("standard", parallel.profile) - - def test_run_parser_rejects_conflicting_explicit_execution_modes(self) -> None: - """Contradictory worker policies fail before creating a collection plan.""" - parser = cli_methods.parser() - conflicts = ( - (["run", "--sequential", "--workers", "2"], 4, "sequential execution"), - (["run", "--sequential", "--threaded"], 4, "legacy --threaded"), - (["run", "--parallel", "--performance-check"], 4, "performance/default"), - (["run", "--parallel", "--default"], 4, "performance/default"), - (["run", "--parallel", "--workers", "1"], 4, "at least two"), - (["run", "--parallel"], 1, "at least two"), - ) - for arguments, default_workers, error in conflicts: - with self.subTest(arguments=arguments, default_workers=default_workers): - with self.assertRaisesRegex(ValueError, error): - cli_methods.request(parser.parse_args(arguments), default_workers=default_workers) - with patch("sys.stderr"), self.assertRaises(SystemExit): - parser.parse_args(["run", "--sequential", "--parallel"]) - - def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work(self) -> None: - """Reruns require a finalized authentic-shaped manifest and explicit original collector IDs.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - manifest_path = root / "manifest.json" - collector_id = "core.system.system_info" - valid_manifest = { - "manifest_schema_version": 1, - "run_id": "run-" + "a" * 32, - "status": "succeeded", - "resolved_plan": [collector_id], - } - manifest_path.write_text(json.dumps(valid_manifest), encoding="utf-8") - parser = cli_methods.parser() - - with self.assertRaisesRegex(ValueError, "explicit --include"): - cli_methods.request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) - with self.assertRaisesRegex(ValueError, "not present in the original"): - cli_methods.request( - parser.parse_args( - ["run", "--rerun-from", str(manifest_path), "--include", "core.system.other"] - ), - 2, - ) - manifest_path.write_text(json.dumps({**valid_manifest, "status": "running"}), encoding="utf-8") - with self.assertRaisesRegex(ValueError, "finalized"): - cli_methods.request( - parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), - 2, - ) - for schema_version in (None, True, 2): - with self.subTest(manifest_schema_version=schema_version): - invalid_manifest = {**valid_manifest, "manifest_schema_version": schema_version} - manifest_path.write_text(json.dumps(invalid_manifest), encoding="utf-8") - with self.assertRaisesRegex(ValueError, "unsupported schema_version"): - cli_methods.request( - parser.parse_args( - ["run", "--rerun-from", str(manifest_path), "--include", collector_id] - ), - 2, - ) - manifest_path.write_text("not json", encoding="utf-8") - with self.assertRaisesRegex(ValueError, "cannot be loaded"): - cli_methods.request( - parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), - 2, - ) - - """Validate the foundation before real core collectors are added.""" - - def test_artifacts_cannot_escape_collector_workspace(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - outside = root / "outside.txt" - outside.write_text("no", encoding="utf-8") - writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) - with self.assertRaises(ArtifactError): - writer.register_file(outside) - linked_source = workspace / "linked-source.txt" - linked_source.symlink_to(outside) - with self.assertRaisesRegex(ArtifactError, "collector workspace"): - writer.register_file(linked_source) - - def test_artifact_destination_symlink_cannot_escape_run_store(self) -> None: - """Pre-existing artifact junctions or symlinks must never redirect evidence outside the run.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - outside = root / "outside" - workspace.mkdir() - artifact_root.mkdir() - outside.mkdir() - source = workspace / "report.txt" - source.write_text("evidence\n", encoding="utf-8") - (artifact_root / "core_system_test").symlink_to(outside, target_is_directory=True) - writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) - - with self.assertRaisesRegex(ArtifactError, "destination"): - writer.register_file(source) - self.assertEqual([], list(outside.iterdir())) - - def test_artifact_writer_enforces_declared_output_name_and_format(self) -> None: - """A core collector cannot silently drift from its published output contract.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - expected = workspace / "system_info.json" - unexpected = workspace / "renamed.json" - expected.write_text("{}\n", encoding="utf-8") - unexpected.write_text("{}\n", encoding="utf-8") - writer = WorkspaceArtifactWriter( - "core.system.system_info", - workspace, - artifact_root, - 1024, - 2, - allowed_relative_paths=("system_info.json",), - allowed_media_types=("application/json",), - ) - - with self.assertRaisesRegex(ArtifactError, "output contract"): - writer.register_file(unexpected, media_type="application/json") - with self.assertRaisesRegex(ArtifactError, "media type"): - writer.register_file(expected, media_type="text/plain") - artifact = writer.register_file(expected, media_type="application/json") - self.assertEqual("core_system_system_info/system_info.json", artifact.relative_path) - - def test_cancelled_artifact_copy_never_publishes_partial_evidence(self) -> None: - """Cancellation must stop staging without creating a final or temporary artifact.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "report.txt" - source.write_text("evidence\n", encoding="utf-8") - cancellation_file = root / ".cancelled" - cancellation_file.touch() - writer = WorkspaceArtifactWriter( - "core.system.test", - workspace, - artifact_root, - 1024, - 1, - cancellation_file=cancellation_file, - ) - - with self.assertRaisesRegex(ArtifactError, "cancelled"): - writer.register_file(source) - self.assertEqual((), writer.artifacts) - self.assertEqual([], list(artifact_root.rglob("*"))) - - def test_mutating_artifact_source_cannot_bypass_declared_byte_limits(self) -> None: - """Growing an evidence file after its initial size check must not publish partial data.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "report.bin" - source.write_bytes(b"1234") - writer = WorkspaceArtifactWriter( - "core.system.test", - workspace, - artifact_root, - 4, - 1, - ) - original_copy = writer._copy_artifact - - def mutate_before_copy(*arguments): - source.write_bytes(b"12345") - return original_copy(*arguments) - - with patch.object(writer, "_copy_artifact", side_effect=mutate_before_copy): - with self.assertRaisesRegex(ArtifactError, "maximum_artifact_bytes"): - writer.register_file(source) - self.assertEqual((), writer.artifacts) - self.assertEqual([], list((artifact_root / "core_system_test").iterdir())) - - def test_cancellation_during_streaming_removes_incomplete_artifact(self) -> None: - """Mid-transfer cancellation must remove temporary evidence and publish nothing.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "large.bin" - source.write_bytes(b"x" * (2 * 1024 * 1024)) - cancellation_file = root / ".cancelled" - writer = WorkspaceArtifactWriter( - "core.system.test", - workspace, - artifact_root, - 3 * 1024 * 1024, - 1, - cancellation_file=cancellation_file, - ) - original_check = writer._check_cancellation - checks = 0 - - def cancel_during_second_chunk() -> None: - nonlocal checks - checks += 1 - if checks == 3: - cancellation_file.touch() - original_check() - - with patch.object(writer, "_check_cancellation", side_effect=cancel_during_second_chunk): - with self.assertRaisesRegex(ArtifactError, "cancelled"): - writer.register_file(source) - self.assertEqual((), writer.artifacts) - self.assertEqual([], list((artifact_root / "core_system_test").iterdir())) - - def test_artifact_registration_enforces_collector_file_count_limit(self) -> None: - """A collector cannot exceed its declared artifact count even with tiny files.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - first = workspace / "first.txt" - second = workspace / "second.txt" - first.write_text("one", encoding="utf-8") - second.write_text("two", encoding="utf-8") - writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 1) - writer.register_file(first, media_type="text/plain") - with self.assertRaisesRegex(ArtifactError, "maximum_artifact_files"): - writer.register_file(second, media_type="text/plain") - - def test_concurrent_artifact_registration_allocates_unique_owned_paths(self) -> None: - """Simultaneous registration of one source cannot overwrite another catalog entry.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "shared.txt" - source.write_text("evidence", encoding="utf-8") - writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 1024, 20) - - with ThreadPoolExecutor(max_workers=10) as executor: - artifacts = list(executor.map(lambda _: writer.register_file(source), range(20))) - - paths = [artifact.relative_path for artifact in artifacts] - self.assertEqual(20, len(set(paths))) - self.assertEqual(20, len(writer.artifacts)) - self.assertTrue(all((artifact_root / path).read_text(encoding="utf-8") == "evidence" for path in paths)) - - def test_concurrent_artifact_registration_cannot_bypass_file_or_byte_limits(self) -> None: - """Parallel callers observe one atomic quota and cannot overfill evidence storage.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "shared.txt" - source.write_text("123", encoding="utf-8") - writer = WorkspaceArtifactWriter("core.system.test", workspace, artifact_root, 6, 2) - - def register(_: int) -> bool: - try: - writer.register_file(source) - except ArtifactError: - return False - return True - - with ThreadPoolExecutor(max_workers=12) as executor: - outcomes = list(executor.map(register, range(24))) - - self.assertEqual(2, sum(outcomes)) - self.assertEqual(2, len(writer.artifacts)) - self.assertEqual(6, sum(artifact.size_bytes for artifact in writer.artifacts)) - self.assertEqual(2, len(list((artifact_root / "core_system_test").iterdir()))) - - def test_artifact_registration_enforces_individual_file_size_limit(self) -> None: - """A collector-specific file ceiling rejects large evidence before it is copied.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - oversized = workspace / "oversized.bin" - oversized.write_bytes(b"12345") - writer = WorkspaceArtifactWriter( - "core.system.test", - workspace, - artifact_root, - 1024, - 5, - maximum_artifact_bytes=4, - ) - with self.assertRaisesRegex(ArtifactError, "maximum_artifact_bytes"): - writer.register_file(oversized) - self.assertEqual((), writer.artifacts) - self.assertEqual([], list(artifact_root.rglob("*"))) - - def test_artifact_registration_enforces_reserved_run_output_budget(self) -> None: - """A worker cannot copy evidence beyond its supervisor-reserved run quota.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "oversized.bin" - source.write_bytes(b"12345") - writer = WorkspaceArtifactWriter( - "core.system.test", - workspace, - artifact_root, - 1024, - 5, - run_output_budget_bytes=4, - ) - with self.assertRaisesRegex(ArtifactError, "maximum_run_output_bytes"): - writer.register_file(source) - self.assertEqual([], list(artifact_root.rglob("*"))) - - def test_run_output_budget_bounds_concurrent_collector_evidence(self) -> None: - """Concurrent isolated workers must never exceed the configured run-wide cap.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - for filename in ("a_first", "b_second"): - source = _delayed_collector_source(filename, 0.1).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n maximum_output_bytes=4,', - ) - (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") - config_path = root / "logicytics.json" - config_path.write_text( - '{"schema_version":4,"runtime":{"maximum_run_output_bytes":7}}', - encoding="utf-8", - ) - configuration = load_config(root) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, configuration).run(plan) - - records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) - self.assertEqual("failed", records["core.system.b_second"].status) - self.assertTrue( - any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors) - ) - self.assertLessEqual(outcome.manifest.total_artifact_bytes, 7) - - def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: - """A completely consumed run quota prevents later workers from launching.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - for filename in ("a_first", "b_second"): - source = _delayed_collector_source(filename, 0.0).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n maximum_output_bytes=4,', - ) - (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") - config_path = root / "logicytics.json" - config_path.write_text( - '{"schema_version":4,"runtime":{"maximum_run_output_bytes":4}}', - encoding="utf-8", - ) - configuration = load_config(root) - plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, configuration).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) - second = records["core.system.b_second"] - self.assertEqual("skipped", second.status) - self.assertIsNone(second.started_at) - self.assertTrue(any("maximum_run_output_bytes" in error for error in second.errors)) - self.assertEqual(4, outcome.manifest.total_artifact_bytes) - - def test_isolated_worker_enforces_declared_individual_artifact_limit(self) -> None: - """Metadata file limits must survive preflight and reach the isolated worker.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n maximum_artifact_bytes=2,', - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - - record = outcome.manifest.collectors[0] - self.assertEqual("failed", record.status) - self.assertTrue(any("maximum_artifact_bytes" in error for error in record.errors)) - self.assertEqual([], record.artifacts) - - def test_artifact_registration_records_validated_provenance(self) -> None: - """Artifact provenance is typed, immutable, and completed by the writer.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - workspace = root / "workspace" - artifact_root = root / "artifacts" - workspace.mkdir() - artifact_root.mkdir() - source = workspace / "report.json" - source.write_text("{}\n", encoding="utf-8") - with self.assertRaisesRegex(ArtifactError, "source_category"): - WorkspaceArtifactWriter("invalid", workspace, artifact_root, 1024, 1) - writer = WorkspaceArtifactWriter( - "plugin.example", - workspace, - artifact_root, - 1024, - 1, - source_category="evidence_graph", - ) - with self.assertRaisesRegex(ArtifactError, "transformations"): - writer.register_file(source, transformations=["normalized"]) # type: ignore[arg-type] - with self.assertRaisesRegex(ArtifactError, "media_type"): - writer.register_file(source, media_type="not a mime type") - with self.assertRaisesRegex(ArtifactError, "evidence_kind"): - writer.register_file(source, evidence_kind="raw") # type: ignore[arg-type] - self.assertEqual((), writer.artifacts) - artifact = writer.register_file( - source, - evidence_kind=EvidenceKind.RAW, - transformations=("normalized",), - ) - self.assertEqual("report.json", artifact.name) - self.assertEqual("registered", artifact.status) - self.assertEqual("evidence_graph", artifact.source_category) - self.assertEqual(EvidenceKind.RAW, artifact.evidence_kind) - datetime.fromisoformat(artifact.collected_at) - self.assertEqual( - ("normalized", "copied into run artifact store"), - artifact.transformations, - ) - - def test_preflight_plan_run_and_package(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - plan = build_plan( - report, - RunRequest(profile="standard", max_workers=1, acknowledge_authorization=True), - ) - configuration = default_config(root) - persisted_states: list[str] = [] - - def capture_manifest_state(path, manifest): - persisted_states.append(manifest.status.value) - write_manifest(path, manifest) - - with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest_state): - outcome = RunSupervisor(root, configuration).run(plan) - self.assertEqual("planned", persisted_states[0]) - self.assertEqual("running", persisted_states[1]) - self.assertEqual("succeeded", persisted_states[-1]) - self.assertEqual("succeeded", outcome.manifest.status.value) - self.assertEqual(("core.system.system_info",), outcome.manifest.resolved_plan) - self.assertEqual(plan.fingerprint, outcome.manifest.plan_fingerprint) - self.assertEqual(64, len(plan.fingerprint)) - self.assertFalse(outcome.manifest.cancellation_requested) - self.assertEqual([], outcome.manifest.errors) - self.assertEqual([], outcome.manifest.skipped_collectors) - self.assertEqual(configuration.runtime.output_root, outcome.run_directory.parent) - self.assertEqual(root / "output" / "data", outcome.run_directory.parent) - self.assertTrue(outcome.run_directory.is_absolute()) - self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info").is_dir()) - self.assertFalse((root / "system.txt").exists()) - self.assertEqual(1, len(outcome.manifest.artifact_list())) - self.assertEqual("run", outcome.manifest.action) - self.assertEqual("4.0", outcome.manifest.engine_version) - self.assertIn("user", outcome.manifest.host) - self.assertIn("is_administrator", outcome.manifest.host) - self.assertIsNotNone(outcome.manifest.collectors[0].heartbeat_at) - self.assertIsNotNone(outcome.manifest.collectors[0].last_progress_at) - self.assertFalse((outcome.run_directory / "collectors" / "core_system_system_info" / "tmp").exists()) - self.assertIsNotNone(outcome.manifest.package) - self.assertTrue(Path(outcome.manifest.package["path"]).is_file()) - self.assertTrue(Path(outcome.manifest.package["sha256_path"]).is_file()) - self.assertEqual(outcome.run_directory / "packages", Path(outcome.manifest.package["path"]).parent) - self.assertEqual(outcome.run_directory / "hashes", Path(outcome.manifest.package["sha256_path"]).parent) - self.assertTrue((outcome.run_directory / "logs" / "engine.jsonl").is_file()) - self.assertFalse((root / "ACCESS").exists()) - self.assertFalse((root / "output" / "RUNS").exists()) - self.assertFalse((root / "output" / "PACKAGES").exists()) - package_path, hash_path = package_run(outcome) - self.assertTrue(package_path.is_file()) - self.assertTrue(hash_path.is_file()) - requested_at = datetime.fromisoformat(outcome.manifest.requested_at) - timestamp = requested_at.strftime("%Y%m%dT%H%M%S.%fZ") - self.assertEqual(f"run-{timestamp}-{outcome.manifest.run_id}.zip", package_path.name) - self.assertEqual(f"{package_path.name}.sha256", hash_path.name) - package_digest, sidecar_name = hash_path.read_text(encoding="ascii").split() - self.assertEqual(package_path.name, sidecar_name) - self.assertEqual(hashlib.sha256(package_path.read_bytes()).hexdigest(), package_digest) - self.assertEqual(package_digest, outcome.manifest.package["sha256"]) - with zipfile.ZipFile(package_path) as archive: - self.assertIsNone(archive.testzip()) - self.assertIn("metadata/manifest.json", archive.namelist()) - self.assertIn("reports/summary.txt", archive.namelist()) - self.assertIn("hashes/artifacts.sha256", archive.namelist()) - self.assertNotIn("logs/performance.json", archive.namelist()) - self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("evidence/")])) - artifact = outcome.manifest.artifact_list()[0] - archived_bytes = archive.read( - f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" - ) - self.assertEqual(artifact.size_bytes, len(archived_bytes)) - self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) - self.assertEqual("system", artifact.source_category) - datetime.fromisoformat(artifact.collected_at) - self.assertEqual(("copied into run artifact store",), artifact.transformations) - packaged_manifest = json.loads(archive.read("metadata/manifest.json")) - self.assertEqual(["core.system.system_info"], packaged_manifest["resolved_plan"]) - self.assertEqual(plan.fingerprint, packaged_manifest["plan_fingerprint"]) - self.assertFalse(packaged_manifest["cancellation_requested"]) - self.assertEqual([], packaged_manifest["errors"]) - self.assertEqual([], packaged_manifest["skipped_collectors"]) - packaged_artifact = packaged_manifest["collectors"][0]["artifacts"][0] - catalog_artifact = packaged_manifest["artifact_catalog"][0] - self.assertEqual(artifact.source_category, packaged_artifact["source_category"]) - self.assertEqual(artifact.collected_at, packaged_artifact["collected_at"]) - self.assertEqual(list(artifact.transformations), packaged_artifact["transformations"]) - self.assertEqual(artifact.id, catalog_artifact["id"]) - self.assertEqual("system.txt", catalog_artifact["name"]) - self.assertEqual("text/plain", catalog_artifact["media_type"]) - self.assertEqual("registered", catalog_artifact["status"]) - self.assertEqual("succeeded", catalog_artifact["producer_status"]) - self.assertEqual("core.system.system_info", catalog_artifact["collector_id"]) - record = outcome.manifest.collectors[0] - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertIn(f"Status: {record.status}", summary) - self.assertIn("Cancellation requested: false", summary) - self.assertIn("Resolved collectors: 1", summary) - self.assertIn(f"Plan fingerprint: {plan.fingerprint}", summary) - self.assertIn(f"Started: {record.started_at}", summary) - self.assertIn(f"Finished: {record.finished_at}", summary) - - repeated = RunSupervisor(root, configuration).run(plan) - self.assertNotEqual(outcome.manifest.run_id, repeated.manifest.run_id) - self.assertNotEqual(outcome.run_directory, repeated.run_directory) - - def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) -> None: - """The versioned package layout is manifest-led and has no ambiguous root entries.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - source = _COLLECTOR.replace( - "CollectorResult, CoreCollector, Specialty", - "CollectorResult, CoreCollector, EvidenceKind, Specialty", - ).replace( - 'output_media_types=("text/plain",)', - 'output_media_types=("text/plain", "application/octet-stream")', - ).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raw = context.workspace / "source.bin"\n' - ' raw.write_bytes(b"raw evidence")\n' - ' raw_artifact = context.artifacts.register_file(\n' - ' raw, evidence_kind=EvidenceKind.RAW,\n' - ' )\n' - ' return CollectorResult.succeeded(\n' - ' "typed artifacts created", (artifact, raw_artifact),\n' - ' )', - ) - collector_path.write_text(source, encoding="utf-8") - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - outcome = RunSupervisor(root, default_config(root)).run( - build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - ) - self.assertEqual("succeeded", outcome.manifest.status.value, outcome.manifest.package) - - package_path = Path(outcome.manifest.package["path"]) - with zipfile.ZipFile(package_path) as archive: - names = archive.namelist() - self.assertEqual( - {"evidence", "hashes", "logs", "metadata", "reports"}, - {name.split("/", 1)[0] for name in names}, - ) - self.assertNotIn("manifest.json", names) - self.assertNotIn("summary.txt", names) - self.assertFalse(any(name.startswith("artifacts/") for name in names)) - artifacts = outcome.manifest.artifact_list() - expected_catalog = "".join( - f"{artifact.sha256} evidence/{artifact.evidence_kind.value}/{artifact.relative_path}\n" - for artifact in sorted( - artifacts, - key=lambda item: f"evidence/{item.evidence_kind.value}/{item.relative_path}", - ) - ) - self.assertEqual( - expected_catalog, - archive.read("hashes/artifacts.sha256").decode("ascii"), - ) - for artifact in artifacts: - archive_name = f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" - self.assertEqual(artifact.sha256, hashlib.sha256(archive.read(archive_name)).hexdigest()) - packaged = json.loads(archive.read("metadata/manifest.json")) - self.assertEqual("1.0", packaged["package_layout_version"]) - self.assertEqual(1, packaged["manifest_schema_version"]) - self.assertEqual("evidence/raw/", packaged["package_sections"]["raw_evidence"]) - self.assertEqual("evidence/derived/", packaged["package_sections"]["derived_reports"]) - self.assertEqual( - {"raw", "derived"}, - {item["evidence_kind"] for item in packaged["artifact_catalog"]}, - ) - outcome.manifest.manifest_schema_version = 2 - with self.assertRaisesRegex(ValueError, "unsupported run manifest schema_version"): - outcome.manifest.to_dict() - - def test_configured_output_root_colocates_packages_without_touching_legacy_evidence(self) -> None: - """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - legacy_evidence = root / "ACCESS" / "RUNS" / "legacy-evidence.txt" - legacy_evidence.parent.mkdir(parents=True) - legacy_evidence.write_text("preserve existing evidence", encoding="utf-8") - (root / "logicytics.json").write_text( - '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', - encoding="utf-8", - ) - configuration = load_config(root) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, configuration).run(plan) - expected_root = root / "custom" / "evidence" - package_path = Path(outcome.manifest.package["path"]) - hash_path = Path(outcome.manifest.package["sha256_path"]) - - self.assertEqual(expected_root, outcome.run_directory.parent) - self.assertEqual(outcome.run_directory / "packages", package_path.parent) - self.assertEqual(outcome.run_directory / "hashes", hash_path.parent) - self.assertEqual("preserve existing evidence", legacy_evidence.read_text(encoding="utf-8")) - self.assertFalse((root / "custom" / "PACKAGES").exists()) - self.assertEqual( - hashlib.sha256(package_path.read_bytes()).hexdigest(), - hash_path.read_text(encoding="ascii").split()[0], - ) - - def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> None: - """Partial terminal outcomes preserve evidence without masquerading as success.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' return CollectorResult.partial(\n' - ' "only one evidence source was available",\n' - ' (artifact,),\n' - ' errors=("secondary evidence source was unavailable",),\n' - ' )', - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - outcome = RunSupervisor(root, default_config(root)).run( - build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - ) - record = outcome.manifest.collectors[0] - - self.assertEqual("partial", outcome.manifest.status.value) - self.assertEqual("partial", record.status) - self.assertEqual(1, len(record.artifacts)) - self.assertIn("secondary evidence source", outcome.manifest.errors[0]["message"]) - package_path = Path(outcome.manifest.package["path"]) - self.assertTrue(package_path.is_file()) - with zipfile.ZipFile(package_path) as archive: - packaged = json.loads(archive.read("metadata/manifest.json")) - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertEqual( - ["ok"], - archive.read("evidence/derived/core_system_system_info/system.txt").decode("utf-8").splitlines(), - ) - self.assertEqual("partial", packaged["status"]) - self.assertEqual("partial", packaged["collectors"][0]["status"]) - self.assertIn("Status: partial", summary) - self.assertIn("secondary evidence source was unavailable", summary) - - def test_selected_collector_rerun_preserves_original_run_and_separates_evidence(self) -> None: - """Explicit reruns execute only selected original IDs and own a distinct evidence package.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - for filename in ("a_original", "z_selected"): - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, 0.0), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - configuration = default_config(root) - original = RunSupervisor(root, configuration).run( - build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - ) - original_manifest = original.manifest_path.read_bytes() - original_package_path = Path(original.manifest.package["path"]) - original_package = original_package_path.read_bytes() - selected_id = "core.system.z_selected" - arguments = cli_methods.parser().parse_args( - [ - "run", - "--rerun-from", - str(original.run_directory), - "--include", - selected_id, - "--acknowledge-authorization", - "--sequential", - ] - ) - request = cli_methods.request(arguments, default_workers=4) - self.assertEqual(original.manifest.run_id, request.rerun_from) - rerun = RunSupervisor(root, configuration).run(build_plan(report, request)) - - self.assertEqual("succeeded", rerun.manifest.status.value) - self.assertEqual("rerun", rerun.manifest.action) - self.assertEqual(original.manifest.run_id, rerun.manifest.parent_run_id) - self.assertEqual((selected_id,), rerun.manifest.resolved_plan) - self.assertEqual([selected_id], [record.id for record in rerun.manifest.collectors]) - self.assertNotEqual(original.run_directory, rerun.run_directory) - self.assertNotEqual(original_package_path, Path(rerun.manifest.package["path"])) - self.assertTrue(original_package_path.name.startswith("run-")) - self.assertTrue(Path(rerun.manifest.package["path"]).name.startswith("rerun-")) - self.assertIn(rerun.manifest.run_id, Path(rerun.manifest.package["path"]).name) - self.assertEqual(original_manifest, original.manifest_path.read_bytes()) - self.assertEqual(original_package, original_package_path.read_bytes()) - with zipfile.ZipFile(Path(rerun.manifest.package["path"])) as archive: - packaged_manifest = json.loads(archive.read("metadata/manifest.json")) - summary = archive.read("reports/summary.txt").decode("utf-8") - artifact_names = [name for name in archive.namelist() if name.startswith("evidence/")] - self.assertEqual(original.manifest.run_id, packaged_manifest["parent_run_id"]) - self.assertEqual("rerun", packaged_manifest["action"]) - self.assertEqual(["evidence/derived/core_system_z_selected/system.txt"], artifact_names) - self.assertIn("Action: rerun", summary) - self.assertIn(f"Parent run: {original.manifest.run_id}", summary) - - def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: - """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' output = context.workspace / "system.txt"', - ' password = context.settings["password"]\n' - ' token = context.settings["nested"]["access_token"]\n' - ' output = context.workspace / "system.txt"', - ).replace( - ' artifact = context.artifacts.register_file(output, media_type="text/plain")', - ' output.write_text(f"password={password} token={token}", encoding="utf-8")\n' - ' context.logger.event("info", f"password={password}", access_token=token)\n' - ' artifact = context.artifacts.register_file(output, media_type="text/plain")', - ).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' return CollectorResult.succeeded(f"password={password}", (artifact,))', - ), - encoding="utf-8", - ) - (root / "logicytics.json").write_text( - json.dumps( - { - "schema_version": 4, - "collectors": { - "core.system.system_info": { - "password": "evidence-password", - "nested": {"access_token": "evidence-token"}, - } - }, - } - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - configuration = replace( - default_config(root), - collector_settings=cast(Any, { - "core.system.system_info": { - "password": "evidence-password", - "nested": {"access_token": "evidence-token"}, - } - }), - ) - outcome = RunSupervisor(root, configuration).run(plan) - self.assertEqual("succeeded", outcome.manifest.status.value) - self.assertEqual("password=[REDACTED]", outcome.manifest.collectors[0].summary) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - artifact = outcome.manifest.artifact_list()[0] - self.assertEqual( - "password=evidence-password token=evidence-token", - archive.read(f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}").decode("utf-8"), - ) - diagnostics = "\n".join( - archive.read(name).decode("utf-8") - for name in archive.namelist() - if not name.startswith("evidence/") - ) - self.assertNotIn("evidence-password", diagnostics) - self.assertNotIn("evidence-token", diagnostics) - self.assertIn("[REDACTED]", diagnostics) - - def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) -> None: - """Collector crashes remain actionable without leaking inline credentials.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' output = context.workspace / "system.txt"', - ' raise RuntimeError("password=crash-password token=crash-token")', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual("failed", outcome.manifest.collectors[0].status) - self.assertIn("RuntimeError", "\n".join(outcome.manifest.collectors[0].errors)) - self.assertEqual("core.system.system_info", outcome.manifest.errors[0]["collector_id"]) - self.assertNotIn("crash-password", outcome.manifest.errors[0]["message"]) - self.assertNotIn("crash-token", outcome.manifest.errors[0]["message"]) - failure = outcome.manifest.collectors[0].failure - self.assertIsNotNone(failure) - assert failure is not None - platform_error = failure["platform_error"] - assert isinstance(platform_error, str) - - self.assertNotIn("crash-password", platform_error) - self.assertNotIn("crash-token", platform_error) - self.assertIn("[REDACTED]", platform_error) - package = outcome.manifest.package - assert package is not None - - with zipfile.ZipFile(Path(package["path"])) as archive: - packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] - self.assertEqual(failure, packaged_record["failure"]) - diagnostics = "\n".join( - archive.read(name).decode("utf-8") - for name in archive.namelist() - if not name.startswith("evidence/") - ) - self.assertNotIn("crash-password", diagnostics) - self.assertNotIn("crash-token", diagnostics) - self.assertIn("[REDACTED]", diagnostics) - - def test_performance_report_is_finalized_before_automatic_packaging(self) -> None: - """Performance-mode timing evidence must be present in the automatic ZIP.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan( - preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - performance_check=True, - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - performance_path = outcome.run_directory / "logs" / "performance.json" - self.assertTrue(performance_path.is_file()) - report = json.loads(performance_path.read_text(encoding="utf-8")) - self.assertEqual(outcome.manifest.run_id, report["run_id"]) - self.assertEqual("core.system.system_info", report["collectors"][0]["id"]) - self.assertIsNotNone(report["collectors"][0]["duration_seconds"]) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_report = json.loads(archive.read("logs/performance.json")) - self.assertEqual(report, packaged_report) - performance_path.unlink() - with self.assertRaisesRegex(FileNotFoundError, "performance report"): - package_run(outcome) - - def test_collector_resource_progress_persists_in_manifest_summary_and_performance_report(self) -> None: - """Incremental progress fields remain visible live and in every packaged diagnostic.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace("from pathlib import Path\n", - "from pathlib import Path\nfrom time import sleep\n").replace( - ' output = context.workspace / "system.txt"', - ' context.report_progress("scan_started", scanned_files=3)\n' - ' sleep(0.2)\n' - ' context.report_progress(\n' - ' "scan_finished", scanned_files=9, copied_files=4, bytes_written=25,\n' - ' observation_count=6, event_count=11,\n' - ' )\n' - ' sleep(0.2)\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - plan = build_plan( - preflight(root), - RunRequest(max_workers=1, acknowledge_authorization=True, performance_check=True), - ) - snapshots: list[dict[str, Any]] = [] - from logicytics.manifest import write_manifest as original_write_manifest - - def capture_manifest(path: Path, manifest: Any) -> None: - snapshots.append(json.loads(json.dumps(manifest.to_dict()))) - original_write_manifest(path, manifest) - - with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest): - outcome = RunSupervisor(root, default_config(root)).run(plan) - record = outcome.manifest.collectors[0] - expected = { - "files_scanned": 9, - "files_copied": 4, - "bytes_written": 25, - "packets_observed": 6, - "events_processed": 11, - } - for field, value in expected.items(): - self.assertEqual(value, record.progress[field]) - self.assertGreater(float(record.progress["elapsed_seconds"]), 0) - self.assertTrue( - any( - snapshot["collectors"][0]["status"] == "running" - and snapshot["collectors"][0]["progress"]["files_scanned"] >= 3 - for snapshot in snapshots - ) - ) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] - report = json.loads(archive.read("logs/performance.json"))["collectors"][0] - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertEqual(record.progress, packaged_record["progress"]) - self.assertEqual(record.progress, report["progress"]) - self.assertEqual(record.peak_memory_bytes, report["peak_memory_bytes"]) - self.assertIn("Files scanned: 9", summary) - self.assertIn("Files copied: 4", summary) - self.assertIn("Bytes written: 25", summary) - self.assertIn("Packets observed: 6", summary) - self.assertIn("Events processed: 11", summary) - - def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self) -> None: - """Only manifest artifacts may enter a package, and their final bytes must match.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - report = preflight(root) - plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - unregistered = outcome.run_directory / "artifacts" / "unregistered.txt" - unregistered.write_text("must not be packaged\n", encoding="utf-8") - - package_path, _ = package_run(outcome) - with zipfile.ZipFile(package_path) as archive: - self.assertNotIn("evidence/derived/unregistered.txt", archive.namelist()) - - original_write = packaging._stream_archive_member - - def tampering_write( - archive: zipfile.ZipFile, - filename: Path, - arcname: str, - ) -> None: - if arcname.startswith("evidence/"): - archive.writestr(arcname, b"tampered artifact bytes") - return - original_write(archive, filename, arcname) - - with patch.object(packaging, "_stream_archive_member", new=tampering_write): - with self.assertRaisesRegex(ValueError, "packaged artifact verification failed"): - package_run(outcome) - self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) - - def test_package_excludes_unregistered_collector_and_engine_jsonl_files(self) -> None: - """Collector-created JSONL files cannot bypass the registered artifact contract.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - collector_workspace = outcome.run_directory / "collectors" / "core_system_system_info" - (collector_workspace / "unregistered.jsonl").write_text("secret workspace data", encoding="utf-8") - (outcome.run_directory / "logs" / "unregistered.jsonl").write_text( - "secret engine data", - encoding="utf-8", - ) - - package_path, _ = package_run(outcome) - with zipfile.ZipFile(package_path) as archive: - self.assertIn("logs/engine.jsonl", archive.namelist()) - self.assertIn("logs/collectors/core_system_system_info/events.jsonl", archive.namelist()) - self.assertNotIn("logs/unregistered.jsonl", archive.namelist()) - self.assertNotIn("collectors/core_system_system_info/unregistered.jsonl", archive.namelist()) - - def test_package_sidecar_failure_restores_existing_verified_package(self) -> None: - """A failed hash publication cannot replace or corrupt a previously verified ZIP.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - package_path = Path(outcome.manifest.package["path"]) - hash_path = Path(outcome.manifest.package["sha256_path"]) - original_package = package_path.read_bytes() - original_hash = hash_path.read_bytes() - original_replace = os.replace - - def fail_sidecar(source: str | Path, destination: str | Path) -> None: - if Path(destination) == hash_path and Path(source).suffix == ".tmp": - raise OSError("simulated sidecar publication failure") - original_replace(source, destination) - - with patch("logicytics.packaging.os.replace", side_effect=fail_sidecar): - with self.assertRaisesRegex(OSError, "sidecar publication failure"): - package_run(outcome) - self.assertEqual(original_package, package_path.read_bytes()) - self.assertEqual(original_hash, hash_path.read_bytes()) - self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) - self.assertFalse(package_path.with_suffix(".zip.backup").exists()) - self.assertFalse(hash_path.with_suffix(".sha256.tmp").exists()) - self.assertFalse(hash_path.with_suffix(".sha256.backup").exists()) - - def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership(self) -> None: - """A modified manifest cannot package outside files or another collector's evidence.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - artifact = outcome.manifest.collectors[0].artifacts[0] - original_path = artifact["relative_path"] - artifact["relative_path"] = "../outside.jsonl" - with self.assertRaisesRegex(ValueError, "escapes its collector-owned store"): - package_run(outcome) - artifact["relative_path"] = original_path - artifact["collector_id"] = "core.system.another" - with self.assertRaisesRegex(ValueError, "collector ownership"): - package_run(outcome) - - def test_package_name_rejects_unsafe_action_run_id_and_naive_timestamps(self) -> None: - """Manipulated manifest identity cannot escape the output root or obscure run timing.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - invalid = ( - ("action", "../outside", "action"), - ("run_id", "../outside", "run_id"), - ("requested_at", "2026-01-01T00:00:00", "timezone"), - ("requested_at", "not a timestamp", "timestamp"), - ) - for field, value, message in invalid: - with self.subTest(field=field, value=value): - original = getattr(outcome.manifest, field) - setattr(outcome.manifest, field, value) - try: - with self.assertRaisesRegex(ValueError, message): - package_run(outcome) - finally: - setattr(outcome.manifest, field, original) - self.assertFalse((root / "outside").exists()) - - def test_package_preserves_nested_registered_artifact_directories(self) -> None: - """Deep collector-owned evidence paths survive catalog publication and ZIP creation.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' output = context.workspace / "system.txt"', - ' output = context.workspace / "nested" / "reports" / "system.txt"\n' - ' output.parent.mkdir(parents=True)', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - expected = "core_system_system_info/nested/reports/system.txt" - self.assertEqual(expected, outcome.manifest.artifact_list()[0].relative_path) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn(f"evidence/derived/{expected}", archive.namelist()) - self.assertEqual( - expected, - json.loads(archive.read("metadata/manifest.json"))["artifact_catalog"][0]["relative_path"], - ) - - def test_package_rejects_forged_names_status_and_duplicate_catalog_identifiers(self) -> None: - """A package is refused whenever its finalized evidence catalog is inconsistent.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' second = context.workspace / "second.txt"\n' - ' second.write_text("second", encoding="utf-8")\n' - ' extra = context.artifacts.register_file(second, media_type="text/plain")\n' - ' return CollectorResult.succeeded("test artifacts created", (artifact, extra))', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - first, second = outcome.manifest.collectors[0].artifacts - original_name = first["name"] - first["name"] = "forged.txt" - with self.assertRaisesRegex(ValueError, "name does not match"): - package_run(outcome) - first["name"] = original_name - first["status"] = "deleted" - with self.assertRaisesRegex(ValueError, "status must be registered"): - package_run(outcome) - first["status"] = "registered" - second["id"] = first["id"] - with self.assertRaisesRegex(ValueError, "duplicate artifact id"): - package_run(outcome) - - def test_package_rejects_collector_event_log_symlinks_outside_the_run(self) -> None: - """A collector event-channel symlink must not smuggle external files into a ZIP.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - outside = root / "outside.jsonl" - outside.write_text("outside credentials", encoding="utf-8") - events = outcome.run_directory / "collectors" / "core_system_system_info" / "events.jsonl" - events.unlink() - events.symlink_to(outside) - - with self.assertRaisesRegex(ValueError, "diagnostic log escapes"): - package_run(outcome) - - def test_package_rejects_manifest_artifact_symlinks_outside_collector_storage(self) -> None: - """Matching bytes cannot make an externally redirected artifact safe to package.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - artifact = outcome.manifest.artifact_list()[0] - stored = outcome.run_directory / "artifacts" / artifact.relative_path - outside = root / "outside.txt" - outside.write_bytes(stored.read_bytes()) - stored.unlink() - stored.symlink_to(outside) - - with self.assertRaisesRegex(ValueError, "escapes its collector-owned store"): - package_run(outcome) - - def test_package_streams_large_evidence_and_hashes_with_bounded_reads(self) -> None: - """Large artifacts must enter ZIP/hash verification without whole-file reads.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' artifact = context.artifacts.register_file(output, media_type="text/plain")', - ' output.write_bytes(b"x" * (2 * 1024 * 1024 + 17))\n' - ' artifact = context.artifacts.register_file(output, media_type="text/plain")', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - artifact = outcome.manifest.artifact_list()[0] - source = outcome.run_directory / "artifacts" / artifact.relative_path - expected_package_path = Path(outcome.manifest.package["path"]).with_suffix(".zip.tmp") - original_open = cast(Callable[..., Any], Path.open) - read_sizes: list[tuple[Path, int]] = [] - - class BoundedReader: - def __init__(self, stream: Any, path: Path) -> None: - self.stream = stream - self.path = path - - def __enter__(self) -> "BoundedReader": - self.stream.__enter__() - return self - - def __exit__(self, *arguments: Any) -> Any: - return self.stream.__exit__(*arguments) - - def read(self, size: int = -1) -> bytes: - if not 0 < size <= 1024 * 1024: - raise AssertionError(f"unbounded evidence read requested: {size}") - read_sizes.append((self.path, size)) - return self.stream.read(size) - - def guarded_open(path: Path, *arguments: Any, **options: Any) -> Any: - stream = original_open(path, *arguments, **options) - if path in {source, expected_package_path} and arguments and arguments[0] == "rb": - return BoundedReader(stream, path) - return stream - - with patch.object(Path, "open", new=guarded_open): - package_path, hash_path = package_run(outcome) - self.assertGreaterEqual(len(read_sizes), 6) - self.assertEqual({source, expected_package_path}, {path for path, _ in read_sizes}) - self.assertEqual(artifact.size_bytes, 2 * 1024 * 1024 + 17) - self.assertTrue(package_path.is_file()) - self.assertTrue(hash_path.is_file()) - - def test_parallel_completion_preserves_deterministic_manifest_order(self) -> None: - """Collector completion order must not reorder the preflighted execution plan.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - - (core_directory / "a_slow.py").write_text( - _delayed_collector_source("a_slow", 0.6), - encoding="utf-8", - ) - (core_directory / "z_fast.py").write_text( - _delayed_collector_source("z_fast", 0.0), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan( - report, - RunRequest(max_workers=2, acknowledge_authorization=True), - ) - planned_ids = [candidate.metadata.id for candidate in plan.collectors] - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) - fast_finished = records["core.system.z_fast"].finished_at - slow_finished = records["core.system.a_slow"].finished_at - assert fast_finished is not None and slow_finished is not None - self.assertLess(fast_finished, slow_finished) - - def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: - """First-class CLI policies determine real sequential versus bounded worker overlap.""" - for execution_mode, expect_overlap in (("--sequential", False), ("--parallel", True)): - with self.subTest(execution_mode=execution_mode), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - for filename in ("a_first", "z_second"): - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, 0.3), - encoding="utf-8", - ) - arguments = cli_methods.parser().parse_args( - ["run", execution_mode, "--acknowledge-authorization"] - ) - request = request(arguments, default_workers=2) - report = preflight(root) - self.assertEqual((), report.invalid) - outcome = RunSupervisor(root, default_config(root)).run(build_plan(report, request)) - records = {record.id: record for record in outcome.manifest.collectors} - first = records["core.system.a_first"] - second = records["core.system.z_second"] - - self.assertEqual("succeeded", first.status, first.errors) - self.assertEqual("succeeded", second.status, second.errors) - if expect_overlap: - assert second.started_at is not None and first.finished_at is not None - self.assertLess(second.started_at, first.finished_at) - else: - assert first.finished_at is not None and second.started_at is not None - self.assertLessEqual(first.finished_at, second.started_at) - - def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: - """A collector declaring parallel_safe=False must run alone between bounded workers.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - fixtures = ( - ("a_parallel", 0.3, True), - ("m_serial", 0.2, False), - ("z_parallel", 0.0, True), - ) - for filename, delay, parallel_safe in fixtures: - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, delay, parallel_safe=parallel_safe), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan( - report, - RunRequest(max_workers=3, acknowledge_authorization=True), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - first = records["core.system.a_parallel"] - serial = records["core.system.m_serial"] - last = records["core.system.z_parallel"] - assert first.finished_at is not None and serial.started_at is not None - self.assertLessEqual(first.finished_at, serial.started_at) - assert serial.finished_at is not None and last.started_at is not None - self.assertLessEqual(serial.finished_at, last.started_at) - - def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: - """Disk, network, and registry resource conflicts each serialize their owners.""" - for resource_class in ( - ResourceClass.DISK_HEAVY, - ResourceClass.NETWORK_HEAVY, - ResourceClass.REGISTRY_SENSITIVE, - ): - with self.subTest(resource_class=resource_class), tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - for filename in ("a_first", "z_second"): - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, 0.15, resource_class=resource_class), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - first = records["core.system.a_first"] - second = records["core.system.z_second"] - - self.assertEqual("succeeded", first.status, first.errors) - self.assertEqual("succeeded", second.status, second.errors) - assert first.finished_at is not None and second.started_at is not None - self.assertLessEqual(first.finished_at, second.started_at) - - def test_independent_resource_classes_preserve_bounded_parallelism(self) -> None: - """Different noninteractive resource owners can overlap in isolated workers.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - fixtures = ( - ("a_disk", ResourceClass.DISK_HEAVY), - ("z_network", ResourceClass.NETWORK_HEAVY), - ) - for filename, resource_class in fixtures: - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, 0.35, resource_class=resource_class), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - self.assertEqual("succeeded", records["core.system.a_disk"].status) - self.assertEqual("succeeded", records["core.system.z_network"].status) - network_started = records["core.system.z_network"].started_at - disk_finished = records["core.system.a_disk"].finished_at - assert network_started is not None and disk_finished is not None - self.assertLess(network_started, disk_finished) - - def test_interactive_resource_class_runs_without_any_worker_overlap(self) -> None: - """Interactive collectors require an exclusive deterministic scheduler window.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - fixtures = ( - ("a_disk", ResourceClass.DISK_HEAVY), - ("m_interactive", ResourceClass.INTERACTIVE), - ("z_general", ResourceClass.GENERAL), - ) - for filename, resource_class in fixtures: - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, 0.15, resource_class=resource_class), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=3, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - disk_finished = records["core.system.a_disk"].finished_at - interactive_started = records["core.system.m_interactive"].started_at - interactive_finished = records["core.system.m_interactive"].finished_at - general_started = records["core.system.z_general"].started_at - assert disk_finished is not None and interactive_started is not None - assert interactive_finished is not None and general_started is not None - self.assertLessEqual(disk_finished, interactive_started) - self.assertLessEqual(interactive_finished, general_started) - - def test_dependencies_finish_before_dependents_start(self) -> None: - """Topological order must become an execution barrier under bounded parallelism.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - dependency_id = "core.system.a_dependency" - (core_directory / "a_dependency.py").write_text( - _delayed_collector_source("a_dependency", 0.3), - encoding="utf-8", - ) - (core_directory / "b_dependent.py").write_text( - _delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - dependency = records[dependency_id] - dependent = records["core.system.b_dependent"] - self.assertEqual("succeeded", dependent.status, dependent.errors) - self.assertIsNotNone(dependency.finished_at) - self.assertIsNotNone(dependent.started_at) - assert dependency.finished_at is not None and dependent.started_at is not None - self.assertLessEqual(dependency.finished_at, dependent.started_at) - - def test_dependency_closure_and_plan_fingerprint_are_deterministic(self) -> None: - """Diamond dependencies resolve once and hash identically regardless of discovery order.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - dependencies = { - "a_root": (), - "b_left": ("core.system.a_root",), - "c_right": ("core.system.a_root",), - "z_target": ("core.system.c_right", "core.system.b_left"), - } - for filename, required in dependencies.items(): - (core_directory / f"{filename}.py").write_text( - _delayed_collector_source(filename, 0.0, dependencies=required), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - request = RunRequest( - include=("core.system.z_target",), - rerun_from="run-" + "a" * 32, - acknowledge_authorization=True, - ) - first = build_plan(report, request) - reversed_plan = build_plan(PreflightReport(tuple(reversed(report.candidates))), request) - expected = ( - "core.system.a_root", - "core.system.b_left", - "core.system.c_right", - "core.system.z_target", - ) - - self.assertEqual(expected, tuple(candidate.metadata.id for candidate in first.collectors)) - self.assertEqual(expected, tuple(candidate.metadata.id for candidate in reversed_plan.collectors)) - self.assertEqual(first.fingerprint, reversed_plan.fingerprint) - self.assertEqual(64, len(first.fingerprint)) - - def test_planner_rejects_excluded_missing_and_sensitive_dependency_conflicts(self) -> None: - """Dependency expansion cannot override explicit exclusions or sensitive opt-in boundaries.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - dependency_id = "core.system.a_dependency" - target_id = "core.system.z_target" - (core_directory / "a_dependency.py").write_text( - _delayed_collector_source("a_dependency", 0.0), - encoding="utf-8", - ) - (core_directory / "z_target.py").write_text( - _delayed_collector_source("z_target", 0.0, dependencies=(dependency_id,)), - encoding="utf-8", - ) - with self.assertRaisesRegex(PlanError, "explicitly excluded"): - build_plan(preflight(root), RunRequest(include=(target_id,), exclude=(dependency_id,))) - sensitive_source = _delayed_collector_source("a_dependency", 0.0).replace( - "from logicytics import CollectorMetadata,", - "from logicytics import Capability, CollectorMetadata,", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.SENSITIVE_FILES,),\n' - ' sensitive_data_categories=("credentials",),\n' - ' default_profiles=("deep",),', - ) - (core_directory / "a_dependency.py").write_text(sensitive_source, encoding="utf-8") - report = preflight(root) - self.assertEqual((), report.invalid) - with self.assertRaisesRegex(PlanError, "sensitive dependency"): - build_plan(report, - RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,))) - approved = build_plan( - report, - RunRequest( - include=(target_id, dependency_id), - approved_capabilities=(Capability.SENSITIVE_FILES,), - ), - ) - self.assertEqual([dependency_id, target_id], [candidate.metadata.id for candidate in approved.collectors]) - (core_directory / "a_dependency.py").unlink() - with self.assertRaisesRegex(PlanError, "unavailable collector"): - build_plan(preflight(root), RunRequest(include=(target_id,))) - (core_directory / "a_dependency.py").write_text( - _delayed_collector_source("a_dependency", 0.0, dependencies=(target_id,)), - encoding="utf-8", - ) - with self.assertRaisesRegex(PlanError, "dependency cycle"): - build_plan(preflight(root), RunRequest(include=(target_id,))) - - def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: - """A failed prerequisite must contain failure and prevent dependent execution.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - dependency_id = "core.system.a_dependency" - (core_directory / "a_dependency.py").write_text( - _delayed_collector_source("a_dependency", 0.0, fail=True), - encoding="utf-8", - ) - (core_directory / "b_dependent.py").write_text( - _delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - self.assertEqual("failed", records[dependency_id].status) - dependent = records["core.system.b_dependent"] - self.assertEqual("skipped", dependent.status) - self.assertIsNone(dependent.started_at) - self.assertTrue(any(dependency_id in error for error in dependent.errors)) - self.assertEqual(["core.system.b_dependent"], outcome.manifest.skipped_collectors) - self.assertEqual( - {dependency_id, "core.system.b_dependent"}, - {error["collector_id"] for error in outcome.manifest.errors}, - ) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = json.loads(archive.read("metadata/manifest.json")) - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertEqual(["core.system.b_dependent"], packaged["skipped_collectors"]) - self.assertIn("Skipped collectors: 1", summary) - - def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: - """An isolated collector crash must produce a durable failed run outcome.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - 'output = context.workspace / "system.txt"\n' - ' output.write_text("ok\\n", encoding="utf-8")\n' - ' artifact = context.artifacts.register_file(output, media_type="text/plain")\n' - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - 'raise RuntimeError("intentional test collector failure")', - ), - encoding="utf-8", - ) - report = preflight(root) - plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual("failed", outcome.manifest.status.value) - record = outcome.manifest.collectors[0] - self.assertEqual("failed", record.status) - self.assertTrue(any("RuntimeError" in error for error in record.errors)) - self.assertIsNotNone(outcome.manifest.package) - package_path = Path(outcome.manifest.package["path"]) - self.assertTrue(package_path.is_file()) - with zipfile.ZipFile(package_path) as archive: - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertIn("Status: failed", summary) - self.assertIn("Reasons:", summary) - self.assertIn("RuntimeError", summary) - self.assertIn(f"Started: {record.started_at}", summary) - self.assertIn(f"Finished: {record.finished_at}", summary) - - def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> None: - """An explicitly retryable collector may recover before any evidence is registered.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' maximum_retries=1,\n' - ' retry_delay_seconds=0.01,', - ).replace( - ' output = context.workspace / "system.txt"', - ' marker = context.workspace / "attempt.marker"\n' - ' if not marker.exists():\n' - ' marker.write_text("first attempt failed", encoding="utf-8")\n' - ' raise RuntimeError("temporary collector failure")\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - - record = outcome.manifest.collectors[0] - self.assertEqual("succeeded", record.status, record.errors) - self.assertIsNone(record.failure) - self.assertEqual(2, record.attempt_count) - self.assertEqual(1, len(record.retry_history)) - self.assertEqual("collect", record.retry_history[0]["failure"]["operation"]) - self.assertTrue(record.retry_history[0]["failure"]["retry_safe"]) - self.assertTrue( - any("temporary collector failure" in error for error in record.retry_history[0]["errors"]) - ) - self.assertIsInstance(record.worker_pid, int) - self.assertIsInstance(record.retry_history[0]["worker_pid"], int) - self.assertNotEqual(record.worker_pid, record.retry_history[0]["worker_pid"]) - self.assertEqual("failed", record.retry_history[0]["termination_reason"]) - self.assertEqual("completed", record.termination_reason) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertEqual(2, packaged_record["attempt_count"]) - self.assertEqual(1, len(packaged_record["retry_history"])) - self.assertIsNone(packaged_record["failure"]) - self.assertTrue(packaged_record["retry_history"][0]["failure"]["retry_safe"]) - self.assertIn("Attempts: 2", summary) - - def test_retry_policy_stops_after_declared_attempt_limit(self) -> None: - """A repeatedly failing collector may not exceed its explicitly declared retry cap.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n maximum_retries=1,', - ).replace( - ' output = context.workspace / "system.txt"', - ' raise RuntimeError("persistent collector failure")', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - record = outcome.manifest.collectors[0] - - self.assertEqual("failed", record.status) - self.assertEqual(2, record.attempt_count) - self.assertEqual(1, len(record.retry_history)) - self.assertTrue(any("persistent collector failure" in error for error in record.errors)) - - def test_failed_collector_with_registered_evidence_is_never_retried(self) -> None: - """Evidence-producing failures must not be repeated or overwrite forensic state.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n maximum_retries=2,', - ).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raise RuntimeError("failure after evidence registration")', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - record = outcome.manifest.collectors[0] - - self.assertEqual("failed", record.status) - self.assertEqual(1, record.attempt_count) - self.assertEqual([], record.retry_history) - self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info" / "system.txt").is_file()) - self.assertEqual(1, len(record.artifacts)) - self.assertEqual(1, len(outcome.manifest.artifact_list())) - - failure = record.failure - assert failure is not None - - self.assertEqual("core.system.system_info", failure["collector_id"]) - self.assertEqual("collect", failure["operation"]) - - platform_error = failure["platform_error"] - assert isinstance(platform_error, str) - - self.assertIn( - "failure after evidence registration", - platform_error, - ) - - self.assertFalse(failure["retry_safe"]) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) - packaged = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertEqual(record.failure, packaged["failure"]) - self.assertIn("Failed operation: collect", summary) - self.assertIn("Retry safe: false", summary) - self.assertIn("Remediation:", summary) - - def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results(self) -> None: - """A failed process finalizes once, preserves evidence, and cannot stop its neighbor.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - failing_source = _delayed_collector_source("a_failed", 0.0).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raise RuntimeError("collection failed after evidence registration")', - ).replace( - ' """Release test resources."""', - ' """Release test resources."""\n' - ' (context.workspace / "cleanup.marker").write_text("cleaned", encoding="utf-8")\n' - ' (context.temporary_directory / "scratch.txt").write_text("scratch", encoding="utf-8")', - ) - (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") - (core_directory / "z_independent.py").write_text( - _delayed_collector_source("z_independent", 0.0), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - failed = records["core.system.a_failed"] - succeeded = records["core.system.z_independent"] - workspace = outcome.run_directory / "collectors" / "core_system_a_failed" - - self.assertEqual("partial", outcome.manifest.status.value) - self.assertEqual("failed", failed.status) - self.assertEqual("succeeded", succeeded.status) - self.assertEqual("cleaned", (workspace / "cleanup.marker").read_text(encoding="utf-8")) - self.assertFalse((workspace / "tmp").exists()) - self.assertEqual(1, len(failed.artifacts)) - self.assertIn("RuntimeError", "\n".join(failed.errors)) - self.assertEqual("process", failed.isolation_mode) - self.assertIsInstance(failed.worker_pid, int) - self.assertEqual("failed", failed.termination_reason) - self.assertEqual("completed", succeeded.termination_reason) - self.assertNotEqual(failed.worker_pid, succeeded.worker_pid) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = { - item["id"]: item - for item in json.loads(archive.read("metadata/manifest.json"))["collectors"] - } - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertIn("evidence/derived/core_system_a_failed/system.txt", archive.namelist()) - self.assertEqual("failed", packaged["core.system.a_failed"]["status"]) - self.assertEqual("process", packaged["core.system.a_failed"]["isolation_mode"]) - self.assertEqual(failed.worker_pid, packaged["core.system.a_failed"]["worker_pid"]) - self.assertEqual("succeeded", packaged["core.system.z_independent"]["status"]) - self.assertIn("Isolation: process", summary) - self.assertIn("Termination: failed", summary) - self.assertIn("collection failed after evidence registration", summary) - - def test_cleanup_failure_preserves_original_collector_crash_and_independent_results(self) -> None: - """Cleanup errors must be reported without replacing the original failure.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - failing_source = _delayed_collector_source("a_failed", 0.0).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raise RuntimeError("original collection failure")', - ).replace( - ' """Release test resources."""', - ' """Release test resources."""\n' - ' raise ValueError("secondary cleanup failure")', - ) - (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") - (core_directory / "z_independent.py").write_text( - _delayed_collector_source("z_independent", 0.0), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - errors = "\n".join(records["core.system.a_failed"].errors) - - self.assertEqual("failed", records["core.system.a_failed"].status) - self.assertIn("RuntimeError: original collection failure", errors) - self.assertIn("collector cleanup failed: ValueError: secondary cleanup failure", errors) - self.assertEqual("succeeded", records["core.system.z_independent"].status) - self.assertEqual("partial", outcome.manifest.status.value) - - def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> None: - """A failed finalizer turns success into failure without discarding registered bytes.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - - collector_path.write_text( - _COLLECTOR.replace( - ' """Release test resources."""', - ' """Release test resources."""\n' - ' raise RuntimeError("finalizer failed after collecting evidence")', - ), - encoding="utf-8", - ) - - plan = build_plan( - preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - ), - ) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - record = outcome.manifest.collectors[0] - - self.assertEqual("failed", record.status) - self.assertEqual("collector cleanup failed", record.summary) - self.assertEqual(1, len(record.artifacts)) - self.assertIn( - "finalizer failed after collecting evidence", - "\n".join(record.errors), - ) - - failure = record.failure - assert failure is not None - - self.assertEqual("cleanup", failure["operation"]) - - remediation = failure["remediation"] - if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) - - self.assertIn("cleanup", remediation) - - retry_safe = failure["retry_safe"] - if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) - - self.assertFalse(retry_safe) - - package = outcome.manifest.package - assert package is not None - assert "path" in package - - with zipfile.ZipFile(Path(package["path"])) as archive: - self.assertIn( - "evidence/derived/core_system_system_info/system.txt", - archive.namelist(), - ) - - def test_collector_timeout_preserves_independent_worker_results(self) -> None: - """A timed-out worker is terminated without cancelling unrelated collection.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - - timeout_source = _delayed_collector_source("a_timeout", 2.0).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' timeout_seconds=1,', - ) - - (core_directory / "a_timeout.py").write_text( - timeout_source, - encoding="utf-8", - ) - - (core_directory / "z_independent.py").write_text( - _delayed_collector_source("z_independent", 0.0), - encoding="utf-8", - ) - - report = preflight(root) - self.assertEqual((), report.invalid) - - plan = build_plan( - report, - RunRequest( - max_workers=2, - acknowledge_authorization=True, - ), - ) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - records = { - record.id: record - for record in outcome.manifest.collectors - } - - timeout_record = records["core.system.a_timeout"] - - self.assertEqual("failed", timeout_record.status) - self.assertTrue( - any("timeout" in error for error in timeout_record.errors) - ) - self.assertEqual( - "timeout_exceeded", - timeout_record.termination_reason, - ) - - timeout_failure = timeout_record.failure - assert timeout_failure is not None - - retry_safe = timeout_failure["retry_safe"] - if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) - - self.assertTrue(retry_safe) - - remediation = timeout_failure["remediation"] - if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) - - self.assertIn("timeout", remediation) - - independent_record = records["core.system.z_independent"] - - self.assertEqual("succeeded", independent_record.status) - self.assertIsNone(independent_record.failure) - self.assertEqual("partial", outcome.manifest.status.value) - - def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> None: - """Direct collector writes outside its private evidence roots fail without affecting peers.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - attempts = ( - "context.workspace.parent / 'z_independent' / 'compromised.txt'", - f"Path({str(root / 'repository-compromised.txt')!r})", - ) - for target in attempts: - with self.subTest(target=target): - source = _delayed_collector_source("a_attacker", 0.0).replace( - ' output = context.workspace / "system.txt"', - f" ({target}).write_text('compromised', encoding='utf-8')\n" - ' output = context.workspace / "system.txt"', - ) - (core_directory / "a_attacker.py").write_text(source, encoding="utf-8") - (core_directory / "z_independent.py").write_text( - _delayed_collector_source("z_independent", 0.0), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("failed", records["core.system.a_attacker"].status) - self.assertIn("escapes its private workspace", "\n".join(records["core.system.a_attacker"].errors)) - self.assertEqual("succeeded", records["core.system.z_independent"].status) - self.assertFalse((root / "repository-compromised.txt").exists()) - self.assertFalse( - (outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists() - ) - - def test_collector_cannot_mutate_its_process_environment(self) -> None: - """A collector must not change process environment or working-directory policy.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace("from pathlib import Path\n", "from pathlib import Path\nimport os\n").replace( - ' output = context.workspace / "system.txt"', - ' os.environ["LOGICYTICS_COLLECTOR_MUTATION"] = "unexpected"\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual("failed", outcome.manifest.collectors[0].status) - self.assertIn("os.putenv", "\n".join(outcome.manifest.collectors[0].errors)) - - def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities(self) -> None: - """Privileged platform access must be declared in metadata, not merely imported.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - - base = _COLLECTOR.replace( - "from pathlib import Path\n", - "from pathlib import Path\n" - "import socket\n" - "import subprocess\n" - "import sys\n" - "import winreg\n", - ) - - attempts = ( - ( - "subprocess.run([sys.executable, '-c', 'pass'], check=False)", - "subprocess capability", - ), - ( - "socket.socket()", - "network capability", - ), - ( - "winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Software')", - "registry_read capability", - ), - ) - - for operation, message in attempts: - with self.subTest(operation=operation): - collector_path.write_text( - base.replace( - ' output = context.workspace / "system.txt"', - f' {operation}\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - - plan = build_plan( - preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - ), - ) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - record = outcome.manifest.collectors[0] - - self.assertEqual("failed", record.status) - self.assertIn( - message, - "\n".join(record.errors), - ) - - failure = record.failure - assert failure is not None - - self.assertEqual("access", failure["operation"]) - - remediation = failure["remediation"] - if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) - - self.assertIn("capability", remediation) - - retry_safe = failure["retry_safe"] - if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) - - self.assertFalse(retry_safe) - - def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities(self) -> None: - """External evidence reads require filesystem access plus every applicable sensitive grant.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - ordinary = root / "outside.txt" - ordinary.write_text("ordinary evidence", encoding="utf-8") - browser = root / "Chrome" / "User Data" / "Default" / "Cookies" - browser.parent.mkdir(parents=True) - browser.write_text("browser evidence", encoding="utf-8") - private_key = root / ".ssh" / "id_ed25519" - private_key.parent.mkdir() - private_key.write_text("private evidence", encoding="utf-8") - cases = ( - (ordinary, (), "filesystem_read capability"), - (ordinary, (Capability.FILESYSTEM_READ,), None), - (browser, (Capability.FILESYSTEM_READ,), "browser_data capability"), - (browser, (Capability.FILESYSTEM_READ, Capability.BROWSER_DATA), "sensitive_files capability"), - ( - private_key, - (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), - "private_keys capability", - ), - ( - private_key, - (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), - None, - ), - ) - for evidence, capabilities, denied in cases: - with self.subTest(evidence=evidence.name, capabilities=capabilities): - declared = ", ".join(f"Capability.{capability.name}" for capability in capabilities) - if len(capabilities) == 1: - declared += "," - collector_path.write_text( - _COLLECTOR.replace( - "from pathlib import Path\n", - "from pathlib import Path\nfrom logicytics import Capability\n", - ).replace( - ' supported_platforms=("win32",),', - f' supported_platforms=("win32",),\n' - f' capabilities=({declared}),', - ).replace( - ' output = context.workspace / "system.txt"', - f" Path({str(evidence)!r}).read_text(encoding='utf-8')\n" - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - plan = build_plan( - preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - approved_capabilities=capabilities, - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - record = outcome.manifest.collectors[0] - if denied is None: - self.assertEqual("succeeded", record.status, record.errors) - else: - self.assertEqual("failed", record.status) - self.assertIn(denied, "\n".join(record.errors)) - - def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self) -> None: - """General network approval must not silently authorize raw packet capture.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport socket\nfrom logicytics import Capability\n", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.NETWORK,),\n' - ' network_access=NetworkAccess.LOCAL,', - ).replace( - ' output = context.workspace / "system.txt"', - ' socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - plan = build_plan( - preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - approved_capabilities=(Capability.NETWORK,), - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual("failed", outcome.manifest.collectors[0].status) - self.assertIn("packet_capture capability", "\n".join(outcome.manifest.collectors[0].errors)) - - def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> None: - """A declared subprocess capability remains usable after explicit request approval.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n capabilities=(Capability.SUBPROCESS,),', - ).replace( - ' output = context.workspace / "system.txt"', - ' subprocess.run([sys.executable, "-c", "pass"], check=True)\n' - ' output = context.workspace / "system.txt"', - ), - encoding="utf-8", - ) - plan = build_plan( - preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - approved_capabilities=(Capability.SUBPROCESS,), - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - self.assertEqual("succeeded", outcome.manifest.collectors[0].status) - - def test_worker_blocks_application_update_power_and_peer_collector_commands_without_stopping_peers(self) -> None: - """Subprocess approval cannot escape the collector role or affect an independent worker.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - peer_path = core_directory / "z_independent.py" - peer_path.write_text(_delayed_collector_source("z_independent", 0.0), encoding="utf-8") - protected_configuration = root / "logicytics.json" - attempts = ( - ([sys.executable, "-m", "logicytics", "preflight"], "main_application"), - ([sys.executable, "-m", "pip", "--version"], "repository_or_package_management"), - (["git", "--version"], "repository_or_package_management"), - (["shutdown", "/?"], "system_power"), - (["cmd", "/c", "shutdown", "/?"], "system_power"), - ( - [ - sys.executable, - "-c", - f"from pathlib import Path; Path({str(protected_configuration)!r}).write_text('bad')", - ], - "configuration_mutation", - ), - ([sys.executable, str(peer_path)], "collector_launch"), - ) - for command, category in attempts: - with self.subTest(command=command): - attacker = _delayed_collector_source("a_attacker", 0.0).replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport subprocess\nfrom logicytics import Capability\n", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.SUBPROCESS,),', - ).replace( - ' output = context.workspace / "system.txt"', - f" subprocess.run({command!r}, check=True)\n" - ' output = context.workspace / "system.txt"', - ) - (core_directory / "a_attacker.py").write_text(attacker, encoding="utf-8") - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan( - report, - RunRequest( - max_workers=2, - acknowledge_authorization=True, - approved_capabilities=(Capability.SUBPROCESS,), - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - - self.assertEqual("failed", records["core.system.a_attacker"].status) - self.assertIn( - f"collector subprocess command is prohibited: {category}", - "\n".join(records["core.system.a_attacker"].errors), - ) - self.assertEqual("succeeded", records["core.system.z_independent"].status) - self.assertEqual("partial", outcome.manifest.status.value) - self.assertFalse(protected_configuration.exists()) - - def test_preflight_rejects_collector_imports_of_application_and_orchestration_services(self) -> None: - """Collectors may import public contracts but never the main application control surface.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - attempts = ( - "import logicytics.cli", - "from logicytics import run_collection", - "from logicytics import packaging", - "import logicytics\nlogicytics.run_collection", - "import logicytics\nlogicytics.configuration", - "from logicytics.api import query_run", - ) - for statement in attempts: - with self.subTest(statement=statement): - collector_path.write_text( - _COLLECTOR.replace("from pathlib import Path\n", f"from pathlib import Path\n{statement}\n"), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("forbidden application import", "\n".join(report.invalid[0].static_errors)) - diagnostic = next( - item for item in report.invalid[0].diagnostics - if "forbidden application import" in item.message - ) - self.assertEqual("static.engine_boundary", diagnostic.rule) - - @unittest.skipUnless(os.name == "nt", "Windows collector subprocess-tree containment") - def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peers(self) -> None: - """A timed-out worker cannot leave its child alive or terminate independent collectors.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - source = _delayed_collector_source("a_tree", 0.0).replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", - ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' capabilities=(Capability.SUBPROCESS,),\n timeout_seconds=2,', - ).replace( - ' output = context.workspace / "system.txt"', - ' child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])\n' - ' (context.workspace / "child.pid").write_text(str(child.pid), encoding="ascii")\n' - ' sleep(5)\n' - ' output = context.workspace / "system.txt"', - ) - (core_directory / "a_tree.py").write_text(source, encoding="utf-8") - (core_directory / "z_independent.py").write_text( - _delayed_collector_source("z_independent", 0.0), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual((), report.invalid) - plan = build_plan( - report, - RunRequest( - max_workers=2, - acknowledge_authorization=True, - approved_capabilities=(Capability.SUBPROCESS,), - ), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - child_pid = int( - (outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text( - encoding="ascii" - ) - ) - handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, child_pid) - if handle: - try: - exit_code = ctypes.c_ulong() - self.assertTrue(ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))) - self.assertNotEqual(259, exit_code.value, "collector subprocess survived worker termination") - finally: - ctypes.windll.kernel32.CloseHandle(handle) - - self.assertEqual("failed", records["core.system.a_tree"].status) - self.assertEqual("timeout_exceeded", records["core.system.a_tree"].termination_reason) - self.assertEqual("succeeded", records["core.system.z_independent"].status) - - @unittest.skipUnless(os.name == "nt", "Windows working-set enforcement test") - def test_collector_memory_limit_preserves_independent_worker_results(self) -> None: - """A memory-limit violation terminates only the offending isolated worker.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_directory = root / "core" / "system" - core_directory.mkdir(parents=True) - (root / "plugins").mkdir() - - limited_source = _delayed_collector_source("a_limited", 2.0).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' maximum_memory_bytes=1,', - ) - - (core_directory / "a_limited.py").write_text( - limited_source, - encoding="utf-8", - ) - - (core_directory / "z_independent.py").write_text( - _delayed_collector_source("z_independent", 0.0), - encoding="utf-8", - ) - - report = preflight(root) - self.assertEqual((), report.invalid) - - plan = build_plan( - report, - RunRequest( - max_workers=2, - acknowledge_authorization=True, - ), - ) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - records = { - record.id: record - for record in outcome.manifest.collectors - } - - limited = records["core.system.a_limited"] - - self.assertEqual("failed", limited.status) - - peak_memory_bytes = limited.peak_memory_bytes - assert peak_memory_bytes is not None - - self.assertGreater(peak_memory_bytes, 1) - self.assertTrue( - any( - "maximum_memory_bytes=1" in error - for error in limited.errors - ) - ) - self.assertEqual( - "memory_limit_exceeded", - limited.termination_reason, - ) - - limited_failure = limited.failure - assert limited_failure is not None - - self.assertEqual( - "collect", - limited_failure["operation"], - ) - - remediation = limited_failure["remediation"] - if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) - - self.assertIn( - "memory limit", - remediation, - ) - - retry_safe = limited_failure["retry_safe"] - if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) - - self.assertTrue(retry_safe) - - independent = records["core.system.z_independent"] - - self.assertEqual("succeeded", independent.status) - self.assertEqual("partial", outcome.manifest.status.value) - - def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: - """Keyboard cancellation must affect only this run and preserve its partial report.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - - collector_path.write_text( - _COLLECTOR, - encoding="utf-8", - ) - - report = preflight(root) - - plan = build_plan( - report, - RunRequest( - max_workers=1, - acknowledge_authorization=True, - ), - ) - - supervisor = RunSupervisor( - root, - default_config(root), - ) - - with patch.object( - supervisor, - supervisor._supervise.__name__, - side_effect=KeyboardInterrupt, - ): - outcome = supervisor.run(plan) - - self.assertEqual("cancelled", outcome.manifest.status.value) - self.assertTrue(outcome.manifest.cancellation_requested) - - record = outcome.manifest.collectors[0] - - self.assertEqual("cancelled", record.status) - self.assertEqual("run cancelled by user", record.summary) - self.assertTrue((outcome.run_directory / ".cancelled").is_file()) - - package = outcome.manifest.package - assert package is not None - assert "path" in package - - package_path = Path(package["path"]) - - self.assertTrue(package_path.is_file()) - - with zipfile.ZipFile(package_path) as archive: - summary = archive.read( - "reports/summary.txt" - ).decode("utf-8") - - packaged_manifest = json.loads( - archive.read("metadata/manifest.json") - ) - - self.assertTrue(packaged_manifest["cancellation_requested"]) - self.assertIn("Status: cancelled", summary) - self.assertIn("Cancellation requested: true", summary) - self.assertIn("Summary: run cancelled by user", summary) - self.assertIn("Started: not started", summary) - - finished_at = record.finished_at - assert finished_at is not None - - self.assertIn( - f"Finished: {finished_at}", - summary, - ) - - def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_path = root / "core" / "system" / "system_info.py" - core_path.parent.mkdir(parents=True) - core_path.write_text('"""Invalid core collector."""\n', encoding="utf-8") - plugin_path = root / "plugins" / "broken_plugin.py" - plugin_path.parent.mkdir() - plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") - report = preflight(root) - self.assertEqual(2, len(report.invalid)) - with self.assertRaises(PreflightError): - build_plan(report, RunRequest()) - - def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_path = root / "core" / "system" / "system_info.py" - core_path.parent.mkdir(parents=True) - core_path.write_text(_COLLECTOR, encoding="utf-8") - - plugin_path = root / "plugins" / "broken_plugin.py" - plugin_path.parent.mkdir() - plugin_path.write_text( - '"""Invalid plugin collector."""\n', - encoding="utf-8", - ) - - report = preflight(root) - payload = report.to_dict() - - valid = payload["valid"] - assert isinstance(valid, list) - assert all(isinstance(item, dict) for item in valid) - - invalid = payload["invalid"] - assert isinstance(invalid, list) - - quarantined = payload["quarantined"] - assert isinstance(quarantined, list) - assert all(isinstance(item, dict) for item in quarantined) - - self.assertEqual( - ["core.system.system_info"], - [item["id"] for item in valid], - ) - self.assertEqual([], invalid) - self.assertEqual( - ["plugin.broken_plugin"], - [item["id"] for item in quarantined], - ) - - quarantined_item = quarantined[0] - - diagnostics = quarantined_item["diagnostics"] - assert isinstance(diagnostics, list) - assert all(isinstance(item, dict) for item in diagnostics) - - diagnostic = diagnostics[0] - - self.assertEqual(str(plugin_path), diagnostic["path"]) - self.assertEqual(1, diagnostic["line"]) - self.assertEqual("static.class_name", diagnostic["rule"]) - - message = diagnostic["message"] - assert isinstance(message, str) - - self.assertIn( - "exactly one public collector class", - message, - ) - - selected = report.to_dict( - selected_plugins=("plugin.broken_plugin",), - ) - - selected_quarantined = selected["quarantined"] - assert isinstance(selected_quarantined, list) - - selected_invalid = selected["invalid"] - assert isinstance(selected_invalid, list) - assert all(isinstance(item, dict) for item in selected_invalid) - - self.assertEqual([], selected_quarantined) - self.assertEqual( - ["plugin.broken_plugin"], - [item["id"] for item in selected_invalid], - ) - - enabled = report.to_dict(enable_plugins=True) - - enabled_invalid = enabled["invalid"] - assert isinstance(enabled_invalid, list) - assert all(isinstance(item, dict) for item in enabled_invalid) - - self.assertEqual( - ["plugin.broken_plugin"], - [item["id"] for item in enabled_invalid], - ) - - plan = build_plan( - report, - RunRequest(), - ) - - self.assertEqual( - ["core.system.system_info"], - [candidate.metadata.id for candidate in plan.collectors], - ) - - def test_invalid_selected_folder_or_enabled_plugin_blocks_planning(self) -> None: - """Folder-owned invalid plugins fail closed by logical ID and explicit enablement.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_path = root / "core" / "system" / "system_info.py" - core_path.parent.mkdir(parents=True) - core_path.write_text(_COLLECTOR, encoding="utf-8") - plugin_path = root / "plugins" / "broken_folder" / "main.py" - plugin_path.parent.mkdir(parents=True) - plugin_path.write_text('"""Invalid folder-owned plugin collector."""\n', encoding="utf-8") - report = preflight(root) - - self.assertEqual("plugin.broken_folder", report.invalid[0].selection_id) - build_plan(report, RunRequest()) - with self.assertRaises(PreflightError): - build_plan(report, RunRequest(include=("plugin.broken_folder",))) - with self.assertRaises(PreflightError): - build_plan(report, RunRequest(enable_plugins=True)) - - def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(self) -> None: - """The public preflight report exposes actionable diagnostics and fail-closed selection.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - core_path = root / "core" / "system" / "system_info.py" - core_path.parent.mkdir(parents=True) - core_path.write_text(_COLLECTOR, encoding="utf-8") - plugin_path = root / "plugins" / "broken_plugin.py" - plugin_path.parent.mkdir() - plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") - - for arguments, expected_exit, key in ( - (["preflight"], 0, "quarantined"), - (["preflight", "--include", "plugin.broken_plugin"], 2, "invalid"), - (["preflight", "--plugins"], 2, "invalid"), - ): - with self.subTest(arguments=arguments): - output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - output, - ): - exit_code = main(arguments) - payload = json.loads(output.getvalue()) - self.assertEqual(expected_exit, exit_code) - self.assertEqual("plugin.broken_plugin", payload[key][0]["id"]) - self.assertEqual(str(plugin_path), payload[key][0]["diagnostics"][0]["path"]) - self.assertIn("rule", payload[key][0]["diagnostics"][0]) - - def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: - """Lifecycle annotations must match the strict collector contract exactly.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace("def collect(self, context: CollectorContext) -> CollectorResult:", - "def collect(self, context: CollectorContext) -> ValidationResult:"), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) - diagnostic = next( - item for item in report.invalid[0].diagnostics - if item.message == "collect must return CollectorResult" - ) - source_lines = collector_path.read_text(encoding="utf-8").splitlines() - expected_line = next( - index for index, line in enumerate(source_lines, start=1) - if "def collect(self, context: CollectorContext) -> ValidationResult:" in line - ) - self.assertEqual(expected_line, diagnostic.line) - self.assertEqual("static.return_annotation", diagnostic.rule) - self.assertEqual(str(collector_path), diagnostic.path) - - def test_worker_runs_typed_prepare_collect_finalize_and_cleanup_lifecycle(self) -> None: - """Every accepted collector executes the complete typed lifecycle inside its worker.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - source = _COLLECTOR.replace( - " def collect(self, context: CollectorContext) -> CollectorResult:", - " def prepare(self, context: CollectorContext) -> ValidationResult:\n" - " \"\"\"Prepare worker-local lifecycle state.\"\"\"\n" - " context.settings['prepared'] = 'yes'\n" - " return ValidationResult(True)\n\n" - " def collect(self, context: CollectorContext) -> CollectorResult:", - ).replace( - " def cleanup(self, context: CollectorContext) -> None:", - " def finalize(self, context: CollectorContext, result: CollectorResult) -> CollectorResult:\n" - " \"\"\"Finalize the typed result before it crosses the worker boundary.\"\"\"\n" - " if context.settings.get('prepared') != 'yes':\n" - " raise RuntimeError('prepare phase did not run')\n" - " return CollectorResult.succeeded('finalized lifecycle', result.artifacts)\n\n" - " def cleanup(self, context: CollectorContext) -> None:", - ) - collector_path.write_text(source, encoding="utf-8") - - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - plan = build_plan( - report, - RunRequest(max_workers=1, acknowledge_authorization=True), - ) - outcome = RunSupervisor(root, default_config(root)).run(plan) - record = outcome.manifest.collectors[0] - self.assertEqual("finalized lifecycle", record.summary) - - def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: - """Optional estimates use the same strict typed contract as lifecycle methods.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - " def cleanup(self, context: CollectorContext) -> None:", - " def estimate(self, context: CollectorContext) -> ValidationResult:\n" - " \"\"\"Return an invalid estimate type for this test.\"\"\"\n" - " return ValidationResult(valid=True)\n\n" - " def cleanup(self, context: CollectorContext) -> None:", - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("estimate must return CollectionEstimate", report.invalid[0].static_errors) - - def test_preflight_rejects_invalid_dependencies_contract(self) -> None: - """Optional dependency declarations must remain typed class-level metadata.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - " def cleanup(self, context: CollectorContext) -> None:", - " def dependencies(self) -> tuple[str, ...]:\n" - " \"\"\"Return an invalid instance-level dependency declaration.\"\"\"\n" - " return ()\n\n" - " def cleanup(self, context: CollectorContext) -> None:", - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("dependencies must be a classmethod", report.invalid[0].static_errors) - - def test_preflight_rejects_dependency_declaration_mismatch(self) -> None: - """The planner-facing metadata and optional dependency method must agree.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - " def cleanup(self, context: CollectorContext) -> None:", - " @classmethod\n" - " def dependencies(cls) -> tuple[str, ...]:\n" - " \"\"\"Return a dependency absent from metadata for this test.\"\"\"\n" - " return ('core.system.missing',)\n\n" - " def cleanup(self, context: CollectorContext) -> None:", - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("dependencies() must match metadata.dependencies", report.invalid[0].runtime_error or "") - - def test_preflight_rejects_malformed_validate_result(self) -> None: - """The isolated preflight probe must reject a non-ValidationResult response.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace("return ValidationResult(True)", "return CollectorResult.succeeded('invalid')"), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("validate() must return ValidationResult", report.invalid[0].runtime_error or "") - - def test_preflight_rejects_validation_filesystem_process_network_and_environment_side_effects(self) -> None: - """No validation probe may mutate files, launch processes, open sockets, or alter its environment.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - outside = root / "outside.marker" - source = _COLLECTOR.replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport os\nimport socket\nimport subprocess\n", - ) - attempts = ( - (f"Path({str(outside)!r}).write_text('unexpected', encoding='utf-8')", "open"), - ("(context.workspace / 'unexpected').mkdir()", "os.mkdir"), - ("subprocess.run(['python', '-c', 'pass'], check=False)", "subprocess.Popen"), - ("socket.socket()", "socket.__new__"), - ("os.environ['LOGICYTICS_PROBE_MUTATION'] = 'unexpected'", "os.putenv"), - ) - for statement, event in attempts: - with self.subTest(event=event): - collector_path.write_text( - source.replace( - " return ValidationResult(True)", - f" {statement}\n return ValidationResult(True)", - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("validate() must not perform side effects", report.invalid[0].runtime_error or "") - self.assertIn(event, report.invalid[0].runtime_error or "") - self.assertFalse(outside.exists()) - - def test_preflight_rejects_validation_artifact_registration_and_exceptions(self) -> None: - """Artifact registration and ordinary validation exceptions block collection.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - attempts = ( - ("context.artifacts.register_file(context.workspace / 'evidence.txt')", "must not register artifacts"), - ("raise RuntimeError('unexpected validation failure')", "unexpected validation failure"), - ) - for statement, message in attempts: - with self.subTest(message=message): - collector_path.write_text( - _COLLECTOR.replace( - " return ValidationResult(True)", - f" {statement}\n return ValidationResult(True)", - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn(message, report.invalid[0].runtime_error or "") - - def test_preflight_rejects_a_hanging_validation_worker(self) -> None: - """A timed-out validation subprocess must quarantine its collector before launch.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text(_COLLECTOR, encoding="utf-8") - with patch.object( - discovery.process_adapter, - discovery.process_adapter.run.__name__, - side_effect=subprocess.TimeoutExpired( - ["validation-worker"], - timeout=10, - ), - ): - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("validation worker failed", report.invalid[0].runtime_error or "") - self.assertIn("timed out", report.invalid[0].runtime_error or "") - - def test_preflight_rejects_collector_print_calls(self) -> None: - """Collectors must emit structured events rather than write directly to stdout.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace("return CollectorResult.succeeded(\"test artifact created\", (artifact,))", - "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))"), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn("collectors must not print; use structured progress or logging", - report.invalid[0].static_errors) - - def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> None: - """Collection-like work must not run before a worker has isolated the collector.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - collector_path.write_text( - _COLLECTOR.replace( - "class SystemInfoCollector(CoreCollector):", - "class SystemInfoCollector(CoreCollector, type(open('unexpected.txt'))):", - ).replace( - " @classmethod\n def metadata", - " marker = input('unexpected prompt')\n\n @classmethod\n def metadata", - ).replace( - "def validate(self, context: CollectorContext) -> ValidationResult:", - "def validate(self, context: CollectorContext = open('another.txt')) -> ValidationResult:", - ), - encoding="utf-8", - ) - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - errors = report.invalid[0].static_errors - self.assertTrue(any("forbidden import-time call: type" in error for error in errors)) - self.assertTrue(any("forbidden import-time call: input" in error for error in errors)) - self.assertTrue(any("forbidden import-time call: open" in error for error in errors)) - - def test_preflight_probe_uses_a_restricted_environment(self) -> None: - """Validation probes expose only documented preflight variables to collectors.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - (root / "plugins").mkdir() - source = _COLLECTOR.replace( - "from pathlib import Path", - "import os\nfrom pathlib import Path", - ).replace( - " return ValidationResult(True)", - " if os.environ.get('LOGICYTICS_VALIDATION') != '1':\n" - " return ValidationResult(False, reasons=('validation flag missing',))\n" - " if os.environ.get('LOGICYTICS_TEST_SECRET') is not None:\n" - " return ValidationResult(False, reasons=('caller environment leaked',))\n" - " return ValidationResult(True)", - 1, - ) - collector_path.write_text(source, encoding="utf-8") - with patch.dict("os.environ", {"LOGICYTICS_TEST_SECRET": "must-not-leak"}): - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - - def test_capability_gate_requires_explicit_approval(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - secured_collector = _COLLECTOR.replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - 'supported_platforms=("win32",),', - 'supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,),', - ) - collector_path.write_text(secured_collector, encoding="utf-8") - report = preflight(root) - with self.assertRaises(PlanError): - build_plan(report, RunRequest()) - plan = build_plan( - report, - RunRequest(approved_capabilities=(Capability.FILESYSTEM_READ,)), - ) - self.assertEqual(1, len(plan.collectors)) - - def test_elevated_collectors_require_approval_and_administrator_privileges(self) -> None: - """Privilege-sensitive collection must fail closed before launching a worker.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - collector_path = root / "core" / "system" / "system_info.py" - collector_path.parent.mkdir(parents=True) - secured_collector = _COLLECTOR.replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - 'supported_platforms=("win32",),', - 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,), ' - 'privilege_level=PrivilegeLevel.ELEVATED,', - ) - collector_path.write_text(secured_collector, encoding="utf-8") - report = preflight(root) - with self.assertRaisesRegex(PlanError, "unapproved capabilities"): - build_plan(report, RunRequest()) - approved = RunRequest(approved_capabilities=(Capability.ELEVATED_PRIVILEGES,)) - for administrator_state in (False, None): - with self.subTest(administrator_state=administrator_state): - environment = EnvironmentReport(administrator_state, True, None) - with patch("logicytics.planner.inspect_environment", return_value=environment): - with self.assertRaisesRegex(PlanError, "administrator account"): - build_plan(report, approved) - with patch( - "logicytics.planner.inspect_environment", - return_value=EnvironmentReport(True, True, None), - ): - plan = build_plan(report, approved) - self.assertEqual(1, len(plan.collectors)) - - def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> None: - """An opt-in privileged plugin must not affect an ordinary core-only plan.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - plugin_path = root / "plugins" / "admin_plugin.py" - plugin_path.parent.mkdir(parents=True) - source = _plugin_collector_source() - source = source.replace("SystemInfoCollector", "AdminPluginCollector") - source = source.replace("core.system.system_info", "plugin.admin_plugin") - source = source.replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - " capabilities=(),", - " capabilities=(Capability.ELEVATED_PRIVILEGES,),", - ).replace( - " privilege_level=PrivilegeLevel.STANDARD,", - " privilege_level=PrivilegeLevel.ELEVATED,", - ) - plugin_path.write_text(source, encoding="utf-8") - report = preflight(root) - self.assertEqual((), report.invalid) - with patch("logicytics.planner.inspect_environment") as inspect: - plan = build_plan(report, RunRequest()) - self.assertEqual((), plan.collectors) - inspect.assert_not_called() - enabled = RunRequest( - enable_plugins=True, - approved_capabilities=(Capability.ELEVATED_PRIVILEGES,), - ) - with patch( - "logicytics.planner.inspect_environment", - return_value=EnvironmentReport(False, True, None), - ): - with self.assertRaisesRegex(PlanError, "administrator account"): - build_plan(report, enabled) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_interaction.py b/tests/test_interaction.py new file mode 100644 index 00000000..dc2d0945 --- /dev/null +++ b/tests/test_interaction.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from logicytics.cli import CLI, main +from logicytics.configuration import ( + load_config, +) +from logicytics.errors import PlanError +from logicytics.interaction import load_history, match_flag, usage_statistics + + +class InteractionTests(unittest.TestCase): + """Local semantic matching, history, and usage behavior.""" + + def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(self) -> None: + """Natural-language actions use configured matching and persist only with consent.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): + self.assertEqual(0, main(["--match", "run a quick basic collection"])) + payload = json.loads(output.getvalue()) + self.assertEqual("minimal", payload["matched_flag"]) + self.assertFalse(payload["history_persisted"]) + self.assertFalse((root / "output" / "data" / "interaction_history.json.gz").exists()) + + (root / "logicytics.json").write_text( + json.dumps({ + "schema_version": 4, + "interaction": { + "history_enabled": True, + "similarity_threshold": 0.5, + "model_name": "stdlib-test-model", + "model_debug": True, + }, + }), + encoding="utf-8", + ) + output = io.StringIO() + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): + self.assertEqual(0, main(["--match", "an exhaustive slow scan"])) + payload = json.loads(output.getvalue()) + self.assertEqual("depth", payload["matched_flag"]) + self.assertEqual("stdlib-test-model", payload["model_debug"]["model_name"]) + history_path = root / "output" / "data" / "interaction_history.json.gz" + history = load_history(history_path) + self.assertEqual(1, len(history)) + self.assertIn("timestamp", history[0]) + self.assertIn("device_name", history[0]) + + output = io.StringIO() + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): + self.assertEqual(0, main(["--usage"])) + usage = json.loads(output.getvalue()) + self.assertEqual(1, usage["total_interactions"]) + self.assertEqual(1, usage["per_flag_frequency"]["depth"]) + graph_path = Path(usage["graph_path"]) + self.assertTrue(graph_path.exists()) + self.assertIn(" None: + """A weak direct match may reuse a stronger local historical association.""" + history = [{"input": "collect the strange moon report", "matched_flag": "debug"}] + result = match_flag( + "collect strange moon report", + threshold=0.99, + model_name="stdlib-sequence-matcher", + history=history, + ) + self.assertEqual("debug", result.matched_flag) + self.assertEqual("history", result.source) + statistics = usage_statistics([{**history[0], "accuracy": 0.9, "device_name": "host"}]) + self.assertEqual("collect the strange moon report", statistics["common_input"]) + + def test_interaction_configuration_rejects_unsafe_or_ambiguous_values(self) -> None: + """Matching and persistence policy must be typed before any history is opened.""" + invalid_sections = ( + {"similarity_threshold": 1.1}, + {"history_enabled": "yes"}, + {"model_debug": 1}, + {"model_name": "bad\nname"}, + {"unknown": True}, + ) + for section in invalid_sections: + with self.subTest(section=section), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "logicytics.json").write_text( + json.dumps({"schema_version": 4, "interaction": section}), + encoding="utf-8", + ) + with self.assertRaises(PlanError): + load_config(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..cbd42cdf --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import io +import json +import os +import tempfile +import unittest +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path +from typing import Any, cast + +from logicytics.command_runner import parse_level_messages, run_command +from logicytics.configuration import ( + LoggingSettings, + load_config, +) +from logicytics.errors import PlanError +from logicytics.logging import ( + ApplicationLogger, + FileEventLogger, + deprecated, + get_application_logger, + get_event_logger, + raise_logged, + timed, +) +from logicytics.output_layout import ensure_output_layout +from tests.fixtures.file_listing import list_files + + +class LoggingTests(unittest.TestCase): + """Logging, output layout, file listing, and command-runner behavior.""" + + def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: + """The application sink is typed, redacted, reusable, colored, and size bounded.""" + + class TerminalBuffer(io.StringIO): + def isatty(self) -> bool: + return True + + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + path = root / "output" / "logs" / "Logicytics.log" + path.parent.mkdir(parents=True) + path.write_text("previous secret\n", encoding="utf-8") + old = path.parent / "Logicytics-old.log" + old.write_text("expired\n", encoding="utf-8") + os.utime(old, (0, 0)) + console = TerminalBuffer() + settings = LoggingSettings( + level="DEBUG", + console_enabled=True, + color_enabled=True, + maximum_bytes=1024, + delete_previous=True, + retention_days=1, + ) + logger = ApplicationLogger(path, settings, console=console) + for level in ( + "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION" + ): + logger.event(level, "typed event", password="hidden", sequence=1) + logger.dispatch(("WARNING: parsed warning", "plain batch row")) + logger.raw("raw access_token=hidden") + logger.separator() + for index in range(40): + logger.event("INFO", "bounded row " + str(index) + " " + "x" * 80) + + contents = path.read_text(encoding="utf-8") + self.assertNotIn("previous secret", contents) + self.assertNotIn("hidden", contents) + self.assertLessEqual(path.stat().st_size, settings.maximum_bytes) + self.assertFalse(old.exists()) + self.assertIn("\033[", console.getvalue()) + self.assertIn("[EXCEPTION]", console.getvalue()) + with self.assertRaisesRegex(ValueError, "unsupported log level"): + logger.event("TRACE", "unsupported") + with self.assertRaisesRegex(ValueError, "raw log end"): + logger.raw("bad", end="\r\n") + + shared = get_application_logger(path, LoggingSettings(console_enabled=False)) + self.assertIs( + shared, + get_application_logger(path, LoggingSettings(console_enabled=False)), + ) + event_path = root / "output" / "data" / "run-test" / "logs" / "engine.jsonl" + engine = get_event_logger(event_path, run_id="run-test") + self.assertIs(engine, get_event_logger(event_path, run_id="run-test")) + collector = get_event_logger( + event_path.parent / "collector.jsonl", + run_id="run-test", + collector_id="core.system.system_info", + ) + self.assertIs( + collector, + get_event_logger( + event_path.parent / "collector.jsonl", + run_id="run-test", + collector_id="core.system.system_info", + ), + ) + self.assertIsNot(engine, collector) + + def test_output_layout_and_logging_configuration_are_complete_and_validated(self) -> None: + """Every global output directory and logging policy value has one typed source.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + layout = ensure_output_layout(root / "output" / "data") + for directory in ( + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, + ): + self.assertTrue(directory.is_dir()) + config_path = root / "logicytics.json" + config_path.write_text( + json.dumps({ + "schema_version": 4, + "logging": { + "level": "debug", + "console_enabled": False, + "color_enabled": False, + "file_enabled": True, + "maximum_bytes": 2048, + "delete_previous": True, + "retention_days": 7, + }, + }), + encoding="utf-8", + ) + configuration = load_config(root, config_path) + self.assertEqual("DEBUG", configuration.logging.level) + self.assertEqual(2048, configuration.logging.maximum_bytes) + self.assertEqual(7, configuration.logging.retention_days) + + for invalid_logging in ( + {"level": "TRACE"}, + {"maximum_bytes": True}, + {"retention_days": -1}, + {"console_enabled": 1}, + {"unknown": True}, + ): + config_path.write_text( + json.dumps({"schema_version": 4, "logging": invalid_logging}), + encoding="utf-8", + ) + with self.subTest(logging=invalid_logging), self.assertRaises(PlanError): + load_config(root, config_path) + + def test_deprecation_decorator_logs_removal_context(self) -> None: + """Deprecated functions must preserve behavior while reporting removal context.""" + events: list[tuple[str, str, dict[str, Any]]] = [] + + class Logger: + @staticmethod + def event(level: str, message: str, **fields: Any) -> None: + events.append((level, message, fields)) + + @deprecated(cast(Any, Logger()), removal_version="5.0", reason="replacement exists") + def old() -> str: + return "still works" + + self.assertEqual("still works", old()) + self.assertEqual("function_deprecated", events[0][1]) + self.assertEqual("5.0", events[0][2]["removal_version"]) + + def test_exception_helper_logs_before_raising(self) -> None: + """Exception helpers must preserve the requested exception type and context.""" + events: list[tuple[str, str, dict[str, Any]]] = [] + + class Logger: + @staticmethod + def event(level: str, message: str, **fields: Any) -> None: + events.append((level, message, fields)) + + with self.assertRaises(ValueError): + raise_logged(cast(Any, Logger()), ValueError, "invalid setting", setting="workers") + self.assertEqual("exception", events[0][0]) + self.assertEqual("ValueError", events[0][2]["exception_type"]) + + def test_timed_decorator_records_function_lifecycle(self) -> None: + """Timing instrumentation must report start and finish through EventLogger.""" + events: list[tuple[str, str, dict[str, Any]]] = [] + + class Logger: + @staticmethod + def event(level: str, message: str, **fields: Any) -> None: + events.append((level, message, fields)) + + @timed(cast(Any, Logger())) + def add(left: int, right: int) -> int: + return left + right + + self.assertEqual(3, add(1, 2)) + self.assertEqual(["function_started", "function_finished"], [event[1] for event in events]) + + def test_structured_event_logger_redacts_secret_fields_and_inline_credentials(self) -> None: + """Structured diagnostics must preserve useful fields without exposing secrets.""" + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "events.jsonl" + logger = FileEventLogger(path, run_id="test-run", collector_id="core.system.private_keys") + logger.event( + "INFO", + "Authorization: Bearer bearer-value password=message-value", + password="field-value", + api_key="api-value", + ordinary="safe", + ) + contents = path.read_text(encoding="utf-8") + for secret in ("bearer-value", "message-value", "field-value", "api-value"): + self.assertNotIn(secret, contents) + event = json.loads(contents) + self.assertEqual("info", event["level"]) + self.assertEqual("core.system.private_keys", event["collector_id"]) + self.assertEqual("[REDACTED]", event["fields"]["password"]) + self.assertEqual("[REDACTED]", event["fields"]["api_key"]) + self.assertEqual("safe", event["fields"]["ordinary"]) + + def test_structured_event_logger_serializes_concurrent_jsonl_events(self) -> None: + """Concurrent diagnostics stay complete, independently parseable, and redacted.""" + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "events.jsonl" + logger = FileEventLogger(path, run_id="concurrent-run", collector_id="core.system.test") + + def write_event(index: int) -> None: + logger.event("info", "collector_progress", sequence=index, password=f"secret-{index}") + + with ThreadPoolExecutor(max_workers=12) as executor: + list(executor.map(write_event, range(120))) + events = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] + + self.assertEqual(120, len(events)) + self.assertEqual(set(range(120)), {event["fields"]["sequence"] for event in events}) + self.assertTrue(all(event["fields"]["password"] == "[REDACTED]" for event in events)) + + def test_file_listing_filters_and_normalizes_files(self) -> None: + """Recursive file discovery must filter extensions and excluded directories.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "collectors").mkdir() + (root / "collectors" / "valid.py").write_text("", encoding="utf-8") + (root / "collectors" / "ignore.txt").write_text("", encoding="utf-8") + (root / ".venv").mkdir() + (root / ".venv" / "hidden.py").write_text("", encoding="utf-8") + files = list_files(root, extensions=(".py",), excluded_directories=(".venv",)) + self.assertEqual((root / "collectors" / "valid.py",), files) + + def test_command_runner_captures_output_and_parses_structured_levels(self) -> None: + """Core command execution must avoid a shell and preserve structured output.""" + result = run_command(("python", "-c", "print('INFO: collected'); print('ordinary')")) + self.assertEqual(0, result.returncode) + self.assertEqual((("INFO", "collected"),), parse_level_messages(result.stdout)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py new file mode 100644 index 00000000..bc594704 --- /dev/null +++ b/tests/test_maintenance.py @@ -0,0 +1,263 @@ +from __future__ import annotations + +import hashlib +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +from logicytics import ( + maintenance, +) +from logicytics.cli import CLI, main +from logicytics.configuration import ( + MaintenanceSettings, + load_config, +) +from logicytics.errors import PlanError +from logicytics.maintenance import ( + build_manifest, + compare_files, + compare_versions, + fetch_remote_manifest, + project_files, + write_local_manifest, +) +from logicytics.sysinternals import ensure_sysinternals + + +class MaintenanceTests(unittest.TestCase): + """Maintenance, integrity manifests, developer actions, and Sysinternals behavior.""" + + def test_maintenance_configuration_requires_pinned_https_and_python_order( + self, + ) -> None: + """Integrity endpoints and Python policy fail closed during configuration loading.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + config = root / "logicytics.json" + valid = { + "schema_version": 4, + "maintenance": { + "remote_manifest_url": "https://example.invalid/project.manifest.json", + "remote_manifest_sha256": "a" * 64, + "local_manifest_path": "integrity/project.json", + "minimum_python": "3.11", + "recommended_python": "3.12", + }, + } + config.write_text(json.dumps(valid), encoding="utf-8") + loaded = load_config(root, config) + self.assertEqual( + Path("integrity/project.json"), loaded.maintenance.local_manifest_path + ) + self.assertEqual("3.12", loaded.maintenance.recommended_python) + + for mutation in ( + {"remote_manifest_url": "http://example.invalid/project.json"}, + {"remote_manifest_sha256": None}, + {"local_manifest_path": "../escape.json"}, + {"minimum_python": "3.12", "recommended_python": "3.11"}, + ): + invalid = json.loads(json.dumps(valid)) + invalid["maintenance"].update(mutation) + config.write_text(json.dumps(invalid), encoding="utf-8") + with self.subTest(mutation=mutation), self.assertRaises(PlanError): + load_config(root, config) + + def test_authenticated_remote_manifest_is_strict_data_only_configuration( + self, + ) -> None: + """Pinned HTTPS bytes are accepted, while unpinned or execution-bearing data is rejected.""" + payload = json.dumps( + { + "schema_version": 1, + "version": "4.1.0-snapshot.2", + "files": {"logicytics/cli.py": "a" * 64}, + } + ).encode("utf-8") + settings = MaintenanceSettings( + remote_manifest_url="https://example.invalid/project.manifest.json", + remote_manifest_sha256=hashlib.sha256(payload).hexdigest(), + ) + response = MagicMock() + response.__enter__.return_value.read.return_value = payload + with patch.object( + maintenance.urllib.request, + "urlopen", + return_value=response, + ): + manifest = fetch_remote_manifest(settings) + + if manifest is None: + self.fail("expected the configured remote manifest to be fetched") + self.assertEqual("4.1.0-snapshot.2", manifest.version) + + tampered = MaintenanceSettings( + remote_manifest_url=settings.remote_manifest_url, + remote_manifest_sha256="0" * 64, + ) + with patch.object( + maintenance.urllib.request, + "urlopen", + return_value=response, + ): + with self.assertRaisesRegex(ValueError, "does not match"): + fetch_remote_manifest(tampered) + + execution_payload = json.dumps( + { + "schema_version": 1, + "version": "4.1.0", + "files": {}, + "collectors": ["remote.code"], + } + ).encode("utf-8") + + execution_response = MagicMock() + execution_response.__enter__.return_value.read.return_value = execution_payload + + execution_settings = MaintenanceSettings( + remote_manifest_url=settings.remote_manifest_url, + remote_manifest_sha256=hashlib.sha256(execution_payload).hexdigest(), + ) + + with patch.object( + maintenance.urllib.request, + "urlopen", + return_value=execution_response, + ): + with self.assertRaisesRegex(ValueError, "only schema_version"): + fetch_remote_manifest(execution_settings) + + def test_integrity_manifest_comparison_and_snapshot_version_ordering(self) -> None: + """Developer integrity reports file states and compare snapshots semantically.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + settings = MaintenanceSettings() + (root / "same.txt").write_text("same", encoding="utf-8") + (root / "changed.txt").write_text("before", encoding="utf-8") + (root / "removed.txt").write_text("remove", encoding="utf-8") + manifest = build_manifest(root, settings, "4.1.0-snapshot.2") + write_local_manifest(root, settings, manifest) + (root / "changed.txt").write_text("after", encoding="utf-8") + (root / "removed.txt").unlink() + (root / "added.txt").write_text("add", encoding="utf-8") + comparison = compare_files(root, settings, manifest) + self.assertEqual(["removed.txt"], comparison["missing"]) + self.assertEqual(["changed.txt"], comparison["modified"]) + self.assertEqual(["added.txt"], comparison["extra"]) + self.assertEqual(["same.txt"], comparison["unchanged"]) + self.assertEqual("behind", compare_versions("4.1.0-snapshot.2", "4.1.0")) + self.assertEqual( + "behind", compare_versions("4.1.0-snapshot.2", "4.1.0-snapshot.10") + ) + cache = root / ".pytest_cache" / "ignored.txt" + cache.parent.mkdir() + cache.write_text("ignored", encoding="utf-8") + tool = root / "tools" / "Sysinternals" / "ignored.exe" + tool.parent.mkdir(parents=True) + tool.write_bytes(b"ignored") + names = {path.relative_to(root).as_posix() for path in project_files(root, settings)} + self.assertNotIn(".pytest_cache/ignored.txt", names) + self.assertNotIn("tools/Sysinternals/ignored.exe", names) + + def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> None: + """Side actions remain separate from collection and write only their dedicated artifacts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "pyproject.toml").write_text( + '[project]\nname = "fixture"\nversion = "4.0.0"\n', + encoding="utf-8", + ) + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + new_callable=io.StringIO, + ): + self.assertEqual( + 0, + main(["dev", "--write-manifest", "--next-version", "4.1.0"]), + ) + + manifest_path = root / "project.manifest.json" + self.assertTrue(manifest_path.is_file()) + manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertEqual("4.1.0", manifest_payload["version"]) + + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + new_callable=io.StringIO, + ): + self.assertEqual(0, main(["debug"])) + debug_path = root / "output" / "logs" / "debug" / "debug.json" + debug = json.loads(debug_path.read_text(encoding="utf-8")) + self.assertEqual("4.0.0", debug["maintenance"]["local_version"]) + self.assertIn( + debug["maintenance"]["python_support"]["status"], + {"recommended", "supported", "incompatible"}, + ) + self.assertEqual("missing", debug["sysinternals"]["status"]) + + def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> None: + """Legacy dev mode preserves comments and updates only its version and file list.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "pyproject.toml").write_text( + '[project]\nname = "fixture"\nversion = "4.0.0"\n', + encoding="utf-8", + ) + collector = root / "core" / "system" / "example.py" + collector.parent.mkdir(parents=True) + collector.write_text('"""Example collector module."""\n', encoding="utf-8") + code = root / "CODE" + code.mkdir() + legacy = code / "config.ini" + legacy.write_text( + "# preserve this comment\n" + "[System Settings]\n" + "version = 3.6.0\n" + 'files = "old.py"\n\n' + "[Unrelated]\n" + "value = untouched\n", + encoding="utf-8", + ) + output = io.StringIO() + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): + self.assertEqual( + 0, + main(["dev", "--write-manifest", "--next-version", "4.1.0"]), + ) + payload = json.loads(output.getvalue()) + self.assertEqual(str(legacy), payload["manifest_written"]) + self.assertEqual([], payload["checks"]["misplaced_python"]) + updated = legacy.read_text(encoding="utf-8") + self.assertIn("# preserve this comment", updated) + self.assertIn("version = 4.1.0", updated) + self.assertIn("CODE/config.ini", updated) + self.assertIn("core/system/example.py", updated) + self.assertIn("value = untouched", updated) + self.assertNotIn("old.py", updated) + self.assertFalse((root / "project.manifest.json").exists()) + + def test_sysinternals_archive_lifecycle_honors_ignore_and_extracts_safely(self) -> None: + """The local bundle must honor opt-out and extract only within its target directory.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / ".ignore-sysinternals").write_text("", encoding="utf-8") + self.assertEqual("ignored", ensure_sysinternals(root).status) + (root / ".ignore-sysinternals").unlink() + with zipfile.ZipFile(root / "SysinternalsSuite.zip", "w") as archive: + archive.writestr("PsInfo.exe", "fixture") + state = ensure_sysinternals(root) + self.assertEqual("extracted", state.status) + self.assertTrue((state.extraction_directory / "PsInfo.exe").is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mods.py b/tests/test_mods.py new file mode 100644 index 00000000..af04ede9 --- /dev/null +++ b/tests/test_mods.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import unittest +import zipfile +from pathlib import Path + +from fixtures.collectors import mod_metadata +from logicytics.cli import cli_methods +from logicytics.configuration import default_config +from logicytics.contracts import Capability, RunRequest +from logicytics.discovery import preflight +from logicytics.errors import PlanError, PreflightError +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class ModTests(unittest.TestCase): + """Legacy MOD discovery, validation, security, and execution behavior.""" + + def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> None: + """Legacy scripts enter the pipeline only through typed metadata and worker isolation.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + + script = mods / "example.py" + script.write_text( + "from pathlib import Path\n" + "Path('report.txt').write_text('mod evidence\\n', encoding='utf-8')\n" + "print('INFO: fixture completed')\n", + encoding="utf-8", + ) + + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn( + "requires metadata sidecar", + report.invalid[0].static_errors[0], + ) + + with self.assertRaises(PreflightError): + build_plan(report, RunRequest(enable_mods=True)) + + script.with_suffix(".py.mod.json").write_text( + json.dumps(mod_metadata("example")), + encoding="utf-8", + ) + + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + self.assertEqual( + (), + build_plan(report, RunRequest()).collectors, + ) + + plan = build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=(Capability.SUBPROCESS,), + acknowledge_authorization=True, + max_workers=1, + ), + ) + + collector_ids: list[str] = [] + for item in plan.collectors: + metadata = item.metadata + if metadata is None: + self.fail("planned collector unexpectedly has no metadata") + collector_ids.append(metadata.id) + + self.assertEqual(["mod.example"], collector_ids) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + record = outcome.manifest.collectors[0] + self.assertEqual( + "succeeded", + record.status, + record.errors, + ) + self.assertEqual(2, len(record.artifacts)) + + report_artifact = next( + item + for item in record.artifacts + if item["name"] == "report.txt" + ) + + artifact_path = ( + outcome.run_directory + / "artifacts" + / str(report_artifact["relative_path"]) + ) + + self.assertEqual( + "mod evidence\n", + artifact_path.read_text(encoding="utf-8"), + ) + + package = outcome.manifest.package + if package is None: + self.fail("successful MOD run unexpectedly has no package metadata") + + mods_package = Path(package["mods_path"]) + mods_hash = Path(package["mods_sha256_path"]) + + self.assertTrue( + mods_package.name.startswith("mods-run-") + ) + self.assertTrue(mods_hash.is_file()) + + with zipfile.ZipFile(mods_package) as archive: + names = archive.namelist() + + self.assertIn( + "metadata/mods.json", + names, + ) + self.assertTrue( + any( + name.endswith("/report.txt") + for name in names + ) + ) + self.assertIsNone(archive.testzip()) + + def test_python_mod_cannot_mutate_project_configuration_without_write_approval( + self, + ) -> None: + """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + configuration_path = root / "logicytics.json" + + configuration_path.write_text( + '{"schema_version":4}\n', + encoding="utf-8", + ) + + mods = root / "MODS" + mods.mkdir() + + scripts = { + "good.py": ( + "from pathlib import Path\n" + "Path('report.txt').write_text('ok', encoding='utf-8')\n" + ), + "malicious.py": ( + "from pathlib import Path\n" + f"Path({str(configuration_path)!r}).write_text(" + "'replaced', encoding='utf-8')\n" + ), + } + + for filename, source in scripts.items(): + script = mods / filename + script.write_text( + source, + encoding="utf-8", + ) + script.with_suffix(".py.mod.json").write_text( + json.dumps(mod_metadata(script.stem)), + encoding="utf-8", + ) + + report = preflight(root) + self.assertEqual( + 2, + len(report.valid), + report.invalid, + ) + + plan = build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=(Capability.SUBPROCESS,), + acknowledge_authorization=True, + max_workers=2, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + records = { + record.id: record + for record in outcome.manifest.collectors + } + + self.assertEqual( + "succeeded", + records["mod.good"].status, + records["mod.good"].errors, + ) + self.assertEqual( + "failed", + records["mod.malicious"].status, + ) + self.assertTrue( + any( + "private workspace" in error + for error in records["mod.malicious"].errors + ), + records["mod.malicious"].errors, + ) + + self.assertEqual( + '{"schema_version":4}\n', + configuration_path.read_text(encoding="utf-8"), + ) + + def test_native_mod_requires_explicit_filesystem_write_declaration( + self, + ) -> None: + """Native child processes are quarantined unless their unconfined write risk is declared.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + + script = mods / "native.bat" + script.write_text( + "@echo off\n", + encoding="utf-8", + ) + script.with_suffix(".bat.mod.json").write_text( + json.dumps(mod_metadata("native")), + encoding="utf-8", + ) + + report = preflight(root) + + self.assertEqual(1, len(report.invalid)) + self.assertIn( + "filesystem_write", + report.invalid[0].runtime_error or "", + ) + + script.with_suffix(".bat.mod.json").write_text( + json.dumps( + mod_metadata( + "native", + filesystem_write=True, + ) + ), + encoding="utf-8", + ) + + report = preflight(root) + + self.assertEqual( + 1, + len(report.valid), + report.invalid, + ) + + with self.assertRaisesRegex( + PlanError, + "filesystem_write", + ): + build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=( + Capability.SUBPROCESS, + ), + ), + ) + + def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers( + self, + ) -> None: + """Compatibility modes include all MODS or only non-Python MODS deterministically.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + + for name, extension in ( + ("python_mod", ".py"), + ("batch_mod", ".bat"), + ): + script = mods / f"{name}{extension}" + script.write_text( + ( + "pass\n" + if extension == ".py" + else "@echo off\n" + ), + encoding="utf-8", + ) + script.with_suffix( + extension + ".mod.json" + ).write_text( + json.dumps( + mod_metadata( + name, + filesystem_write=extension != ".py", + ) + ), + encoding="utf-8", + ) + + report = preflight(root) + + self.assertEqual( + 2, + len(report.valid), + report.invalid, + ) + + modded = build_plan( + report, + RunRequest( + enable_mods=True, + approved_capabilities=( + Capability.SUBPROCESS, + Capability.FILESYSTEM_WRITE, + ), + ), + ) + + nopy = build_plan( + report, + RunRequest( + enable_mods=True, + non_python_only=True, + approved_capabilities=( + Capability.SUBPROCESS, + Capability.FILESYSTEM_WRITE, + ), + ), + ) + + modded_ids: list[str] = [] + for item in modded.collectors: + metadata = item.metadata + if metadata is None: + self.fail( + "planned MOD collector unexpectedly has no metadata" + ) + modded_ids.append(metadata.id) + + nopy_ids: list[str] = [] + for item in nopy.collectors: + metadata = item.metadata + if metadata is None: + self.fail( + "planned non-Python MOD collector unexpectedly has no metadata" + ) + nopy_ids.append(metadata.id) + + self.assertEqual( + ["mod.batch_mod", "mod.python_mod"], + modded_ids, + ) + self.assertEqual( + ["mod.batch_mod"], + nopy_ids, + ) + + parser = cli_methods.parser() + + modded_request = cli_methods.request( + parser.parse_args(["run", "--modded"]), + 2, + ) + self.assertTrue(modded_request.enable_mods) + + nopy_request = cli_methods.request( + parser.parse_args(["run", "--nopy"]), + 2, + ) + self.assertTrue(nopy_request.enable_mods) + self.assertTrue(nopy_request.non_python_only) + + @unittest.skipUnless( + os.name == "nt", + "legacy script adapters require Windows", + ) + def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files( + self, + ) -> None: + """Every documented non-Python MODS type executes through its explicit adapter.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + mods = root / "MODS" + mods.mkdir() + + scripts = { + "powershell_mod.ps1": ( + "Set-Content -LiteralPath report.txt " + "-Value 'powershell evidence'\n" + ), + "batch_mod.bat": ( + "@echo off\r\n" + "echo batch evidence>report.txt\r\n" + ), + } + + for filename, contents in scripts.items(): + script = mods / filename + script.write_text( + contents, + encoding="utf-8", + ) + script.with_suffix( + script.suffix + ".mod.json" + ).write_text( + json.dumps( + mod_metadata( + script.stem, + filesystem_write=True, + ) + ), + encoding="utf-8", + ) + + executable = ( + Path( + os.environ.get( + "WINDIR", + r"C:\Windows", + ) + ) + / "System32" + / "whoami.exe" + ) + + self.assertTrue(executable.is_file()) + + copied_executable = ( + mods / "identity_mod.exe" + ) + shutil.copy2( + executable, + copied_executable, + ) + + copied_executable.with_suffix( + ".exe.mod.json" + ).write_text( + json.dumps( + mod_metadata( + "identity_mod", + filesystem_write=True, + ) + ), + encoding="utf-8", + ) + + report = preflight(root) + + self.assertEqual( + 3, + len(report.valid), + report.invalid, + ) + + plan = build_plan( + report, + RunRequest( + enable_mods=True, + non_python_only=True, + approved_capabilities=( + Capability.SUBPROCESS, + Capability.FILESYSTEM_WRITE, + ), + acknowledge_authorization=True, + max_workers=1, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + self.assertEqual( + [ + "succeeded", + "succeeded", + "succeeded", + ], + [ + record.status + for record in outcome.manifest.collectors + ], + [ + record.errors + for record in outcome.manifest.collectors + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 00000000..301a7742 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,882 @@ +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +import unittest +import zipfile +from dataclasses import replace +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, cast +from unittest.mock import patch + +from fixtures.collectors import COLLECTOR, delayed_collector_source +from logicytics import packaging +from logicytics.cli import cli_methods +from logicytics.configuration import ( + default_config, + load_config, +) +from logicytics.contracts import ( + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.manifest import write_manifest +from logicytics.packaging import package_run +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class PackagingTests(unittest.TestCase): + """Package construction, verification, redaction, and publication behavior.""" + + def test_preflight_plan_run_and_package(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + plan = build_plan( + report, + RunRequest(profile="standard", max_workers=1, acknowledge_authorization=True), + ) + configuration = default_config(root) + persisted_states: list[str] = [] + + def capture_manifest_state(path, manifest): + persisted_states.append(manifest.status.value) + write_manifest(path, manifest) + + with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest_state): + outcome = RunSupervisor(root, configuration).run(plan) + self.assertEqual("planned", persisted_states[0]) + self.assertEqual("running", persisted_states[1]) + self.assertEqual("succeeded", persisted_states[-1]) + self.assertEqual("succeeded", outcome.manifest.status.value) + self.assertEqual(("core.system.system_info",), outcome.manifest.resolved_plan) + self.assertEqual(plan.fingerprint, outcome.manifest.plan_fingerprint) + self.assertEqual(64, len(plan.fingerprint)) + self.assertFalse(outcome.manifest.cancellation_requested) + self.assertEqual([], outcome.manifest.errors) + self.assertEqual([], outcome.manifest.skipped_collectors) + self.assertEqual(configuration.runtime.output_root, outcome.run_directory.parent) + self.assertEqual(root / "output" / "data", outcome.run_directory.parent) + self.assertTrue(outcome.run_directory.is_absolute()) + self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info").is_dir()) + self.assertFalse((root / "system.txt").exists()) + self.assertEqual(1, len(outcome.manifest.artifact_list())) + self.assertEqual("run", outcome.manifest.action) + self.assertEqual("4.0", outcome.manifest.engine_version) + self.assertIn("user", outcome.manifest.host) + self.assertIn("is_administrator", outcome.manifest.host) + self.assertIsNotNone(outcome.manifest.collectors[0].heartbeat_at) + self.assertIsNotNone(outcome.manifest.collectors[0].last_progress_at) + self.assertFalse((outcome.run_directory / "collectors" / "core_system_system_info" / "tmp").exists()) + package = outcome.manifest.package + assert package is not None + self.assertTrue(Path(package["path"]).is_file()) + self.assertTrue(Path(package["sha256_path"]).is_file()) + self.assertEqual(outcome.run_directory / "packages", Path(package["path"]).parent) + self.assertEqual(outcome.run_directory / "hashes", Path(package["sha256_path"]).parent) + self.assertTrue((outcome.run_directory / "logs" / "engine.jsonl").is_file()) + self.assertFalse((root / "ACCESS").exists()) + self.assertFalse((root / "output" / "RUNS").exists()) + self.assertFalse((root / "output" / "PACKAGES").exists()) + package_path, hash_path = package_run(outcome) + self.assertTrue(package_path.is_file()) + self.assertTrue(hash_path.is_file()) + requested_at = datetime.fromisoformat(outcome.manifest.requested_at) + timestamp = requested_at.strftime("%Y%m%dT%H%M%S.%fZ") + self.assertEqual(f"run-{timestamp}-{outcome.manifest.run_id}.zip", package_path.name) + self.assertEqual(f"{package_path.name}.sha256", hash_path.name) + package_digest, sidecar_name = hash_path.read_text(encoding="ascii").split() + self.assertEqual(package_path.name, sidecar_name) + self.assertEqual(hashlib.sha256(package_path.read_bytes()).hexdigest(), package_digest) + self.assertEqual(package_digest, package["sha256"]) + with zipfile.ZipFile(package_path) as archive: + self.assertIsNone(archive.testzip()) + self.assertIn("metadata/manifest.json", archive.namelist()) + self.assertIn("reports/summary.txt", archive.namelist()) + self.assertIn("hashes/artifacts.sha256", archive.namelist()) + self.assertNotIn("logs/performance.json", archive.namelist()) + self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("evidence/")])) + artifact = outcome.manifest.artifact_list()[0] + archived_bytes = archive.read( + f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" + ) + self.assertEqual(artifact.size_bytes, len(archived_bytes)) + self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) + self.assertEqual("system", artifact.source_category) + datetime.fromisoformat(artifact.collected_at) + self.assertEqual(("copied into run artifact store",), artifact.transformations) + packaged_manifest = json.loads(archive.read("metadata/manifest.json")) + self.assertEqual(["core.system.system_info"], packaged_manifest["resolved_plan"]) + self.assertEqual(plan.fingerprint, packaged_manifest["plan_fingerprint"]) + self.assertFalse(packaged_manifest["cancellation_requested"]) + self.assertEqual([], packaged_manifest["errors"]) + self.assertEqual([], packaged_manifest["skipped_collectors"]) + packaged_artifact = packaged_manifest["collectors"][0]["artifacts"][0] + catalog_artifact = packaged_manifest["artifact_catalog"][0] + self.assertEqual(artifact.source_category, packaged_artifact["source_category"]) + self.assertEqual(artifact.collected_at, packaged_artifact["collected_at"]) + self.assertEqual(list(artifact.transformations), packaged_artifact["transformations"]) + self.assertEqual(artifact.id, catalog_artifact["id"]) + self.assertEqual("system.txt", catalog_artifact["name"]) + self.assertEqual("text/plain", catalog_artifact["media_type"]) + self.assertEqual("registered", catalog_artifact["status"]) + self.assertEqual("succeeded", catalog_artifact["producer_status"]) + self.assertEqual("core.system.system_info", catalog_artifact["collector_id"]) + record = outcome.manifest.collectors[0] + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertIn(f"Status: {record.status}", summary) + self.assertIn("Cancellation requested: false", summary) + self.assertIn("Resolved collectors: 1", summary) + self.assertIn(f"Plan fingerprint: {plan.fingerprint}", summary) + self.assertIsNotNone(record.started_at) + self.assertIsNotNone(record.finished_at) + + started_at = record.started_at + finished_at = record.finished_at + + assert started_at is not None + assert finished_at is not None + + self.assertIn(f"Started: {started_at}", summary) + self.assertIn(f"Finished: {finished_at}", summary) + + repeated = RunSupervisor(root, configuration).run(plan) + self.assertNotEqual(outcome.manifest.run_id, repeated.manifest.run_id) + self.assertNotEqual(outcome.run_directory, repeated.run_directory) + + def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) -> None: + """The versioned package layout is manifest-led and has no ambiguous root entries.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + source = COLLECTOR.replace( + "CollectorResult, CoreCollector, Specialty", + "CollectorResult, CoreCollector, EvidenceKind, Specialty", + ).replace( + 'output_media_types=("text/plain",)', + 'output_media_types=("text/plain", "application/octet-stream")', + ).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raw = context.workspace / "source.bin"\n' + ' raw.write_bytes(b"raw evidence")\n' + ' raw_artifact = context.artifacts.register_file(\n' + ' raw, evidence_kind=EvidenceKind.RAW,\n' + ' )\n' + ' return CollectorResult.succeeded(\n' + ' "typed artifacts created", (artifact, raw_artifact),\n' + ' )', + ) + collector_path.write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + outcome = RunSupervisor(root, default_config(root)).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + ) + package = outcome.manifest.package + assert package is not None + self.assertEqual("succeeded", outcome.manifest.status.value, package) + + package_path = Path(package["path"]) + with zipfile.ZipFile(package_path) as archive: + names = archive.namelist() + self.assertEqual( + {"evidence", "hashes", "logs", "metadata", "reports"}, + {name.split("/", 1)[0] for name in names}, + ) + self.assertNotIn("manifest.json", names) + self.assertNotIn("summary.txt", names) + self.assertFalse(any(name.startswith("artifacts/") for name in names)) + artifacts = outcome.manifest.artifact_list() + expected_catalog = "".join( + f"{artifact.sha256} evidence/{artifact.evidence_kind.value}/{artifact.relative_path}\n" + for artifact in sorted( + artifacts, + key=lambda item: f"evidence/{item.evidence_kind.value}/{item.relative_path}", + ) + ) + self.assertEqual( + expected_catalog, + archive.read("hashes/artifacts.sha256").decode("ascii"), + ) + for artifact in artifacts: + archive_name = f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" + self.assertEqual(artifact.sha256, hashlib.sha256(archive.read(archive_name)).hexdigest()) + packaged = json.loads(archive.read("metadata/manifest.json")) + self.assertEqual("1.0", packaged["package_layout_version"]) + self.assertEqual(1, packaged["manifest_schema_version"]) + self.assertEqual("evidence/raw/", packaged["package_sections"]["raw_evidence"]) + self.assertEqual("evidence/derived/", packaged["package_sections"]["derived_reports"]) + self.assertEqual( + {"raw", "derived"}, + {item["evidence_kind"] for item in packaged["artifact_catalog"]}, + ) + outcome.manifest.manifest_schema_version = 2 + with self.assertRaisesRegex(ValueError, "unsupported run manifest schema_version"): + outcome.manifest.to_dict() + + def test_configured_output_root_colocates_packages_without_touching_legacy_evidence(self) -> None: + """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + legacy_evidence = root / "ACCESS" / "RUNS" / "legacy-evidence.txt" + legacy_evidence.parent.mkdir(parents=True) + legacy_evidence.write_text("preserve existing evidence", encoding="utf-8") + (root / "logicytics.json").write_text( + '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', + encoding="utf-8", + ) + configuration = load_config(root) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + package = outcome.manifest.package + assert package is not None + expected_root = root / "custom" / "evidence" + package_path = Path(package["path"]) + hash_path = Path(package["sha256_path"]) + + self.assertEqual(expected_root, outcome.run_directory.parent) + self.assertEqual(outcome.run_directory / "packages", package_path.parent) + self.assertEqual(outcome.run_directory / "hashes", hash_path.parent) + self.assertEqual("preserve existing evidence", legacy_evidence.read_text(encoding="utf-8")) + self.assertFalse((root / "custom" / "PACKAGES").exists()) + self.assertEqual( + hashlib.sha256(package_path.read_bytes()).hexdigest(), + hash_path.read_text(encoding="ascii").split()[0], + ) + + def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> None: + """Partial terminal outcomes preserve evidence without masquerading as success.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' return CollectorResult.partial(\n' + ' "only one evidence source was available",\n' + ' (artifact,),\n' + ' errors=("secondary evidence source was unavailable",),\n' + ' )', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + outcome = RunSupervisor(root, default_config(root)).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + ) + record = outcome.manifest.collectors[0] + + self.assertEqual("partial", outcome.manifest.status.value) + self.assertEqual("partial", record.status) + self.assertEqual(1, len(record.artifacts)) + self.assertIn("secondary evidence source", outcome.manifest.errors[0]["message"]) + package = outcome.manifest.package + assert package is not None + package_path = Path(package["path"]) + self.assertTrue(package_path.is_file()) + with zipfile.ZipFile(package_path) as archive: + packaged = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertEqual( + ["ok"], + archive.read("evidence/derived/core_system_system_info/system.txt").decode("utf-8").splitlines(), + ) + self.assertEqual("partial", packaged["status"]) + self.assertEqual("partial", packaged["collectors"][0]["status"]) + self.assertIn("Status: partial", summary) + self.assertIn("secondary evidence source was unavailable", summary) + + def test_selected_collector_rerun_preserves_original_run_and_separates_evidence(self) -> None: + """Explicit reruns execute only selected original IDs and own a distinct evidence package.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_original", "z_selected"): + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + configuration = default_config(root) + original = RunSupervisor(root, configuration).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + ) + original_package_metadata = original.manifest.package + assert original_package_metadata is not None + original_manifest = original.manifest_path.read_bytes() + original_package_path = Path(original_package_metadata["path"]) + original_package = original_package_path.read_bytes() + selected_id = "core.system.z_selected" + arguments = cli_methods.parser().parse_args( + [ + "run", + "--rerun-from", + str(original.run_directory), + "--include", + selected_id, + "--acknowledge-authorization", + "--sequential", + ] + ) + request = cli_methods.request(arguments, default_workers=4) + self.assertEqual(original.manifest.run_id, request.rerun_from) + rerun = RunSupervisor(root, configuration).run(build_plan(report, request)) + rerun_package_metadata = rerun.manifest.package + assert rerun_package_metadata is not None + rerun_package_path = Path(rerun_package_metadata["path"]) + + self.assertEqual("succeeded", rerun.manifest.status.value) + self.assertEqual("rerun", rerun.manifest.action) + self.assertEqual(original.manifest.run_id, rerun.manifest.parent_run_id) + self.assertEqual((selected_id,), rerun.manifest.resolved_plan) + self.assertEqual([selected_id], [record.id for record in rerun.manifest.collectors]) + self.assertNotEqual(original.run_directory, rerun.run_directory) + self.assertNotEqual(original_package_path, rerun_package_path) + self.assertTrue(original_package_path.name.startswith("run-")) + self.assertTrue(rerun_package_path.name.startswith("rerun-")) + self.assertIn(rerun.manifest.run_id, rerun_package_path.name) + self.assertEqual(original_manifest, original.manifest_path.read_bytes()) + self.assertEqual(original_package, original_package_path.read_bytes()) + with zipfile.ZipFile(rerun_package_path) as archive: + packaged_manifest = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") + artifact_names = [name for name in archive.namelist() if name.startswith("evidence/")] + self.assertEqual(original.manifest.run_id, packaged_manifest["parent_run_id"]) + self.assertEqual("rerun", packaged_manifest["action"]) + self.assertEqual(["evidence/derived/core_system_z_selected/system.txt"], artifact_names) + self.assertIn("Action: rerun", summary) + self.assertIn(f"Parent run: {original.manifest.run_id}", summary) + + def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: + """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' password = context.settings["password"]\n' + ' token = context.settings["nested"]["access_token"]\n' + ' output = context.workspace / "system.txt"', + ).replace( + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ' output.write_text(f"password={password} token={token}", encoding="utf-8")\n' + ' context.logger.event("info", f"password={password}", access_token=token)\n' + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' return CollectorResult.succeeded(f"password={password}", (artifact,))', + ), + encoding="utf-8", + ) + (root / "logicytics.json").write_text( + json.dumps( + { + "schema_version": 4, + "collectors": { + "core.system.system_info": { + "password": "evidence-password", + "nested": {"access_token": "evidence-token"}, + } + }, + } + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + configuration = replace( + default_config(root), + collector_settings=cast(Any, { + "core.system.system_info": { + "password": "evidence-password", + "nested": {"access_token": "evidence-token"}, + } + }), + ) + outcome = RunSupervisor(root, configuration).run(plan) + self.assertEqual("succeeded", outcome.manifest.status.value) + self.assertEqual("password=[REDACTED]", outcome.manifest.collectors[0].summary) + package = outcome.manifest.package + assert package is not None + with zipfile.ZipFile(Path(package["path"])) as archive: + artifact = outcome.manifest.artifact_list()[0] + self.assertEqual( + "password=evidence-password token=evidence-token", + archive.read(f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}").decode("utf-8"), + ) + diagnostics = "\n".join( + archive.read(name).decode("utf-8") + for name in archive.namelist() + if not name.startswith("evidence/") + ) + self.assertNotIn("evidence-password", diagnostics) + self.assertNotIn("evidence-token", diagnostics) + self.assertIn("[REDACTED]", diagnostics) + + def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) -> None: + """Collector crashes remain actionable without leaking inline credentials.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' raise RuntimeError("password=crash-password token=crash-token")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn("RuntimeError", "\n".join(outcome.manifest.collectors[0].errors)) + self.assertEqual("core.system.system_info", outcome.manifest.errors[0]["collector_id"]) + self.assertNotIn("crash-password", outcome.manifest.errors[0]["message"]) + self.assertNotIn("crash-token", outcome.manifest.errors[0]["message"]) + failure = outcome.manifest.collectors[0].failure + self.assertIsNotNone(failure) + assert failure is not None + platform_error = failure["platform_error"] + assert isinstance(platform_error, str) + + self.assertNotIn("crash-password", platform_error) + self.assertNotIn("crash-token", platform_error) + self.assertIn("[REDACTED]", platform_error) + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + self.assertEqual(failure, packaged_record["failure"]) + diagnostics = "\n".join( + archive.read(name).decode("utf-8") + for name in archive.namelist() + if not name.startswith("evidence/") + ) + self.assertNotIn("crash-password", diagnostics) + self.assertNotIn("crash-token", diagnostics) + self.assertIn("[REDACTED]", diagnostics) + + def test_performance_report_is_finalized_before_automatic_packaging(self) -> None: + """Performance-mode timing evidence must be present in the automatic ZIP.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + performance_check=True, + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + performance_path = outcome.run_directory / "logs" / "performance.json" + self.assertTrue(performance_path.is_file()) + report = json.loads(performance_path.read_text(encoding="utf-8")) + self.assertEqual(outcome.manifest.run_id, report["run_id"]) + self.assertEqual("core.system.system_info", report["collectors"][0]["id"]) + self.assertIsNotNone(report["collectors"][0]["duration_seconds"]) + package = outcome.manifest.package + assert package is not None + with zipfile.ZipFile(Path(package["path"])) as archive: + packaged_report = json.loads(archive.read("logs/performance.json")) + self.assertEqual(report, packaged_report) + performance_path.unlink() + with self.assertRaisesRegex(FileNotFoundError, "performance report"): + package_run(outcome) + + def test_collector_resource_progress_persists_in_manifest_summary_and_performance_report(self) -> None: + """Incremental progress fields remain visible live and in every packaged diagnostic.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace("from pathlib import Path\n", + "from pathlib import Path\nfrom time import sleep\n").replace( + ' output = context.workspace / "system.txt"', + ' context.report_progress("scan_started", scanned_files=3)\n' + ' sleep(0.2)\n' + ' context.report_progress(\n' + ' "scan_finished", scanned_files=9, copied_files=4, bytes_written=25,\n' + ' observation_count=6, event_count=11,\n' + ' )\n' + ' sleep(0.2)\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest(max_workers=1, acknowledge_authorization=True, performance_check=True), + ) + snapshots: list[dict[str, Any]] = [] + from logicytics.manifest import write_manifest as original_write_manifest + + def capture_manifest(path: Path, manifest: Any) -> None: + snapshots.append(json.loads(json.dumps(manifest.to_dict()))) + original_write_manifest(path, manifest) + + with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest): + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + expected = { + "files_scanned": 9, + "files_copied": 4, + "bytes_written": 25, + "packets_observed": 6, + "events_processed": 11, + } + for field, value in expected.items(): + self.assertEqual(value, record.progress[field]) + self.assertGreater(float(record.progress["elapsed_seconds"]), 0) + self.assertTrue( + any( + snapshot["collectors"][0]["status"] == "running" + and snapshot["collectors"][0]["progress"]["files_scanned"] >= 3 + for snapshot in snapshots + ) + ) + package = outcome.manifest.package + assert package is not None + with zipfile.ZipFile(Path(package["path"])) as archive: + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + report = json.loads(archive.read("logs/performance.json"))["collectors"][0] + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertEqual(record.progress, packaged_record["progress"]) + self.assertEqual(record.progress, report["progress"]) + self.assertEqual(record.peak_memory_bytes, report["peak_memory_bytes"]) + self.assertIn("Files scanned: 9", summary) + self.assertIn("Files copied: 4", summary) + self.assertIn("Bytes written: 25", summary) + self.assertIn("Packets observed: 6", summary) + self.assertIn("Events processed: 11", summary) + + def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self) -> None: + """Only manifest artifacts may enter a package, and their final bytes must match.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + report = preflight(root) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + unregistered = outcome.run_directory / "artifacts" / "unregistered.txt" + unregistered.write_text("must not be packaged\n", encoding="utf-8") + + package_path, _ = package_run(outcome) + with zipfile.ZipFile(package_path) as archive: + self.assertNotIn("evidence/derived/unregistered.txt", archive.namelist()) + + original_write = packaging._stream_archive_member + + def tampering_write( + archive: zipfile.ZipFile, + filename: Path, + arcname: str, + ) -> None: + if arcname.startswith("evidence/"): + archive.writestr(arcname, b"tampered artifact bytes") + return + original_write(archive, filename, arcname) + + with patch.object(packaging, "_stream_archive_member", new=tampering_write): + with self.assertRaisesRegex(ValueError, "packaged artifact verification failed"): + package_run(outcome) + self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) + + def test_package_excludes_unregistered_collector_and_engine_jsonl_files(self) -> None: + """Collector-created JSONL files cannot bypass the registered artifact contract.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + collector_workspace = outcome.run_directory / "collectors" / "core_system_system_info" + (collector_workspace / "unregistered.jsonl").write_text("secret workspace data", encoding="utf-8") + (outcome.run_directory / "logs" / "unregistered.jsonl").write_text( + "secret engine data", + encoding="utf-8", + ) + + package_path, _ = package_run(outcome) + with zipfile.ZipFile(package_path) as archive: + self.assertIn("logs/engine.jsonl", archive.namelist()) + self.assertIn("logs/collectors/core_system_system_info/events.jsonl", archive.namelist()) + self.assertNotIn("logs/unregistered.jsonl", archive.namelist()) + self.assertNotIn("collectors/core_system_system_info/unregistered.jsonl", archive.namelist()) + + def test_package_sidecar_failure_restores_existing_verified_package(self) -> None: + """A failed hash publication cannot replace or corrupt a previously verified ZIP.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + package = outcome.manifest.package + assert package is not None + package_path = Path(package["path"]) + hash_path = Path(package["sha256_path"]) + original_package = package_path.read_bytes() + original_hash = hash_path.read_bytes() + original_replace = os.replace + + def fail_sidecar(source: str | Path, destination: str | Path) -> None: + if Path(destination) == hash_path and Path(source).suffix == ".tmp": + raise OSError("simulated sidecar publication failure") + original_replace(source, destination) + + with patch("logicytics.packaging.os.replace", side_effect=fail_sidecar): + with self.assertRaisesRegex(OSError, "sidecar publication failure"): + package_run(outcome) + self.assertEqual(original_package, package_path.read_bytes()) + self.assertEqual(original_hash, hash_path.read_bytes()) + self.assertFalse(package_path.with_suffix(".zip.tmp").exists()) + self.assertFalse(package_path.with_suffix(".zip.backup").exists()) + self.assertFalse(hash_path.with_suffix(".sha256.tmp").exists()) + self.assertFalse(hash_path.with_suffix(".sha256.backup").exists()) + + def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership(self) -> None: + """A modified manifest cannot package outside files or another collector's evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + artifact = outcome.manifest.collectors[0].artifacts[0] + original_path = artifact["relative_path"] + artifact["relative_path"] = "../outside.jsonl" + with self.assertRaisesRegex(ValueError, "escapes its collector-owned store"): + package_run(outcome) + artifact["relative_path"] = original_path + artifact["collector_id"] = "core.system.another" + with self.assertRaisesRegex(ValueError, "collector ownership"): + package_run(outcome) + + def test_package_name_rejects_unsafe_action_run_id_and_naive_timestamps(self) -> None: + """Manipulated manifest identity cannot escape the output root or obscure run timing.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + invalid = ( + ("action", "../outside", "action"), + ("run_id", "../outside", "run_id"), + ("requested_at", "2026-01-01T00:00:00", "timezone"), + ("requested_at", "not a timestamp", "timestamp"), + ) + for field, value, message in invalid: + with self.subTest(field=field, value=value): + original = getattr(outcome.manifest, field) + setattr(outcome.manifest, field, value) + try: + with self.assertRaisesRegex(ValueError, message): + package_run(outcome) + finally: + setattr(outcome.manifest, field, original) + self.assertFalse((root / "outside").exists()) + + def test_package_preserves_nested_registered_artifact_directories(self) -> None: + """Deep collector-owned evidence paths survive catalog publication and ZIP creation.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' output = context.workspace / "nested" / "reports" / "system.txt"\n' + ' output.parent.mkdir(parents=True)', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + expected = "core_system_system_info/nested/reports/system.txt" + self.assertEqual(expected, outcome.manifest.artifact_list()[0].relative_path) + package = outcome.manifest.package + assert package is not None + with zipfile.ZipFile(Path(package["path"])) as archive: + self.assertIn(f"evidence/derived/{expected}", archive.namelist()) + self.assertEqual( + expected, + json.loads(archive.read("metadata/manifest.json"))["artifact_catalog"][0]["relative_path"], + ) + + def test_package_rejects_forged_names_status_and_duplicate_catalog_identifiers(self) -> None: + """A package is refused whenever its finalized evidence catalog is inconsistent.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' second = context.workspace / "second.txt"\n' + ' second.write_text("second", encoding="utf-8")\n' + ' extra = context.artifacts.register_file(second, media_type="text/plain")\n' + ' return CollectorResult.succeeded("test artifacts created", (artifact, extra))', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + first, second = outcome.manifest.collectors[0].artifacts + original_name = first["name"] + first["name"] = "forged.txt" + with self.assertRaisesRegex(ValueError, "name does not match"): + package_run(outcome) + first["name"] = original_name + first["status"] = "deleted" + with self.assertRaisesRegex(ValueError, "status must be registered"): + package_run(outcome) + first["status"] = "registered" + second["id"] = first["id"] + with self.assertRaisesRegex(ValueError, "duplicate artifact id"): + package_run(outcome) + + def test_package_rejects_collector_event_log_symlinks_outside_the_run(self) -> None: + """A collector event-channel symlink must not smuggle external files into a ZIP.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + outside = root / "outside.jsonl" + outside.write_text("outside credentials", encoding="utf-8") + events = outcome.run_directory / "collectors" / "core_system_system_info" / "events.jsonl" + events.unlink() + events.symlink_to(outside) + + with self.assertRaisesRegex(ValueError, "diagnostic log escapes"): + package_run(outcome) + + def test_package_rejects_manifest_artifact_symlinks_outside_collector_storage(self) -> None: + """Matching bytes cannot make an externally redirected artifact safe to package.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + artifact = outcome.manifest.artifact_list()[0] + stored = outcome.run_directory / "artifacts" / artifact.relative_path + outside = root / "outside.txt" + outside.write_bytes(stored.read_bytes()) + stored.unlink() + stored.symlink_to(outside) + + with self.assertRaisesRegex(ValueError, "escapes its collector-owned store"): + package_run(outcome) + + def test_package_streams_large_evidence_and_hashes_with_bounded_reads(self) -> None: + """Large artifacts must enter ZIP/hash verification without whole-file reads.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ' output.write_bytes(b"x" * (2 * 1024 * 1024 + 17))\n' + ' artifact = context.artifacts.register_file(output, media_type="text/plain")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + artifact = outcome.manifest.artifact_list()[0] + source = outcome.run_directory / "artifacts" / artifact.relative_path + package = outcome.manifest.package + assert package is not None + expected_package_path = Path(package["path"]).with_suffix(".zip.tmp") + original_open = cast(Callable[..., Any], Path.open) + read_sizes: list[tuple[Path, int]] = [] + + class BoundedReader: + def __init__(self, stream: Any, path: Path) -> None: + self.stream = stream + self.path = path + + def __enter__(self) -> "BoundedReader": + self.stream.__enter__() + return self + + def __exit__(self, *arguments: Any) -> Any: + return self.stream.__exit__(*arguments) + + def read(self, size: int = -1) -> bytes: + if not 0 < size <= 1024 * 1024: + raise AssertionError(f"unbounded evidence read requested: {size}") + read_sizes.append((self.path, size)) + return self.stream.read(size) + + def guarded_open(path: Path, *arguments: Any, **options: Any) -> Any: + stream = original_open(path, *arguments, **options) + if path in {source, expected_package_path} and arguments and arguments[0] == "rb": + return BoundedReader(stream, path) + return stream + + with patch.object(Path, "open", new=guarded_open): + package_path, hash_path = package_run(outcome) + self.assertGreaterEqual(len(read_sizes), 6) + self.assertEqual({source, expected_package_path}, {path for path, _ in read_sizes}) + self.assertEqual(artifact.size_bytes, 2 * 1024 * 1024 + 17) + self.assertTrue(package_path.is_file()) + self.assertTrue(hash_path.is_file()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_planning.py b/tests/test_planning.py new file mode 100644 index 00000000..08209313 --- /dev/null +++ b/tests/test_planning.py @@ -0,0 +1,591 @@ +from __future__ import annotations + +import io +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fixtures.collectors import COLLECTOR, delayed_collector_source +from logicytics.cli import CLI, cli_methods, main +from logicytics.configuration import ( + default_config, + load_config, +) +from logicytics.contracts import ( + Capability, + OutputPolicy, + PostRunAction, + RunRequest, + RunStatus, +) +from logicytics.discovery import PreflightReport, preflight +from logicytics.environment import EnvironmentReport +from logicytics.errors import PlanError +from logicytics.logging import ( + FileEventLogger, +) +from logicytics.planner import BUILTIN_PROFILES, build_plan +from logicytics.platform_adapters import ProcessAdapter +from logicytics.runtime import RunSupervisor + + +class PlanningTests(unittest.TestCase): + """Selection, profiles, authorization, dependencies, and planning behavior.""" + + def _collector_ids(self, candidates) -> list[str]: + """Return collector IDs while requiring discovered metadata to be present.""" + collector_ids: list[str] = [] + for candidate in candidates: + metadata = candidate.metadata + if metadata is None: + self.fail("planned collector is missing metadata") + collector_ids.append(metadata.id) + return collector_ids + + def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( + self, + ) -> None: + """Power actions remain explicit and cannot run before durable package publication.""" + arguments = cli_methods.parser().parse_args([ + "run", + "--shutdown", + "--performance-check", + "--acknowledge-authorization", + ]) + + request = cli_methods.request(arguments, default_workers=4) + + self.assertEqual(PostRunAction.SHUTDOWN, request.post_run_action) + self.assertEqual(OutputPolicy.PACKAGE, request.output_policy) + self.assertEqual(1, request.max_workers) + + with self.assertRaises(SystemExit): + cli_methods.parser().parse_args([ + "run", + "--reboot", + "--shutdown", + ]) + + with self.assertRaisesRegex(ValueError, "require packaged output"): + cli_methods.request( + cli_methods.parser().parse_args([ + "run", + "--reboot", + "--no-package", + ]), + default_workers=1, + ) + + with tempfile.TemporaryDirectory() as temporary: + logger = FileEventLogger( + Path(temporary) / "events.jsonl", + run_id="run-" + "a" * 32, + ) + + manifest = type( + "Manifest", + (), + { + "status": RunStatus.SUCCEEDED, + "package": { + "path": "evidence.zip", + "sha256": "a" * 64, + }, + }, + )() + + completed = subprocess.CompletedProcess( + ["shutdown"], + 0, + "", + "", + ) + + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + return_value=completed, + ) as command: + RunSupervisor._execute_post_run_action( + PostRunAction.REBOOT, + manifest, + logger, + ) + + self.assertEqual("shutdown", command.call_args.args[0][0]) + self.assertEqual("/r", command.call_args.args[0][1]) + self.assertEqual("60", command.call_args.args[0][3]) + + def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) -> None: + """Default collection excludes sensitive evidence unless explicitly selected.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + (core_directory / "a_standard.py").write_text( + delayed_collector_source("a_standard", 0.0), + encoding="utf-8", + ) + sensitive_id = "core.system.z_sensitive" + source = delayed_collector_source("z_sensitive", 0.0).replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SENSITIVE_FILES,),\n' + ' sensitive_data_categories=("credentials",),\n' + ' default_profiles=("deep",),', + ) + (core_directory / "z_sensitive.py").write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + + standard = build_plan(report, RunRequest()) + self.assertEqual(["core.system.a_standard"], self._collector_ids(standard.collectors)) + with self.assertRaisesRegex(PlanError, "unapproved capabilities"): + build_plan(report, RunRequest(include=(sensitive_id,))) + opted_in = build_plan( + report, + RunRequest( + include=(sensitive_id,), + exclude=("core.system.a_standard",), + approved_capabilities=(Capability.SENSITIVE_FILES,), + ), + ) + self.assertEqual([sensitive_id], self._collector_ids(opted_in.collectors)) + deep = build_plan( + report, + RunRequest(profile="deep", approved_capabilities=(Capability.SENSITIVE_FILES,)), + ) + self.assertEqual([sensitive_id], self._collector_ids(deep.collectors)) + + def test_builtin_profiles_select_declared_members_and_reject_unknown_names(self) -> None: + """Only documented profiles resolve collector-declared membership before any run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + memberships = { + "a_essential": ("minimal", "standard", "deep", "offline"), + "b_standard": ("standard", "deep"), + "z_deep": ("deep",), + } + for filename, profiles in memberships.items(): + source = delayed_collector_source(filename, 0.0).replace( + ' supported_platforms=("win32",),', + f' supported_platforms=("win32",),\n default_profiles={profiles!r},', + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + expected = { + "minimal": ["core.system.a_essential"], + "standard": ["core.system.a_essential", "core.system.b_standard"], + "deep": ["core.system.a_essential", "core.system.b_standard", "core.system.z_deep"], + "offline": ["core.system.a_essential"], + } + self.assertEqual(set(expected), set(BUILTIN_PROFILES)) + for profile, collector_ids in expected.items(): + with self.subTest(profile=profile): + plan = build_plan(report, RunRequest(profile=profile)) + self.assertEqual(collector_ids, self._collector_ids(plan.collectors)) + with self.assertRaisesRegex(PlanError, "unknown collection profile"): + build_plan(report, RunRequest(profile="invented")) + with patch("sys.stderr", new_callable=io.StringIO) as errors: + with self.assertRaises(SystemExit): + cli_methods.parser().parse_args(["plan", "--profile", "invented"]) + self.assertIn("invalid choice", errors.getvalue()) + + def test_offline_profile_rejects_network_collectors_even_with_explicit_approval(self) -> None: + """Offline collection remains local-only regardless of include and capability overrides.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + collector_id = "core.system.network_source" + source = delayed_collector_source("network_source", 0.0).replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.NETWORK,),\n' + ' network_access=NetworkAccess.LOCAL,\n' + ' default_profiles=("standard",),', + ) + (core_directory / "network_source.py").write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + standard = build_plan(report, RunRequest(approved_capabilities=(Capability.NETWORK,))) + self.assertEqual([collector_id], self._collector_ids(standard.collectors)) + self.assertEqual((), build_plan(report, RunRequest(profile="offline")).collectors) + with self.assertRaisesRegex(PlanError, "offline profile prohibits network-capable"): + build_plan( + report, + RunRequest(profile="offline", include=(collector_id,), approved_capabilities=(Capability.NETWORK,)), + ) + + def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) -> None: + """Collection consent must explain requested evidence before creating a workspace.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text( + COLLECTOR.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SENSITIVE_FILES,),\n' + ' sensitive_data_categories=("credentials", "personal_documents"),\n' + ' default_profiles=("deep",),', + ), + encoding="utf-8", + ) + configuration = default_config(root) + plan = build_plan( + preflight(root), + RunRequest(profile="deep", approved_capabilities=(Capability.SENSITIVE_FILES,)), + ) + with self.assertRaises(PermissionError) as rejected: + RunSupervisor(root, configuration).run(plan) + message = str(rejected.exception) + self.assertIn("--acknowledge-authorization", message) + self.assertIn("selected categories: system", message) + self.assertIn("sensitive outputs: credentials, personal_documents", message) + self.assertFalse(configuration.runtime.output_root.exists()) + + def test_run_request_rejects_invalid_selection_and_execution_policy(self) -> None: + """Run requests must be immutable, typed declarations before a plan exists.""" + collector_id = "core.system.system_info" + invalid_requests = ( + ({"profile": "Standard"}, "profile"), + ({"include": [collector_id]}, "include"), + ({"include": ("../system",)}, "include"), + ({"include": (collector_id, collector_id)}, "include"), + ({"include": (collector_id,), "exclude": (collector_id,)}, "overlap"), + ({"enable_plugins": 1}, "enable_plugins"), + ({"acknowledge_authorization": 1}, "acknowledge_authorization"), + ({"performance_check": 1}, "performance_check"), + ({"performance_check": True, "max_workers": 2}, "performance_check"), + ({"max_workers": True}, "max_workers"), + ({"max_workers": 65}, "max_workers"), + ({"rerun_from": "../outside", "include": (collector_id,)}, "rerun_from"), + ({"rerun_from": "run-" + "a" * 32}, "explicit included"), + ({"approved_capabilities": ("filesystem_read",)}, "approved_capabilities"), + ( + {"approved_capabilities": (Capability.FILESYSTEM_READ, Capability.FILESYSTEM_READ)}, + "approved_capabilities", + ), + ) + for options, message in invalid_requests: + with self.subTest(options=options): + with self.assertRaisesRegex(ValueError, message): + RunRequest(**options) + + def test_configured_worker_limit_blocks_oversized_run_before_workspace_creation(self) -> None: + """The configured maximum worker count bounds a run before any collector starts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', + encoding="utf-8", + ) + configuration = load_config(root) + self.assertEqual(1, configuration.runtime.default_max_workers) + self.assertEqual(2, configuration.runtime.maximum_workers) + plan = build_plan(preflight(root), RunRequest(max_workers=3, acknowledge_authorization=True)) + with self.assertRaisesRegex(ValueError, "maximum_workers"): + RunSupervisor(root, configuration).run(plan) + self.assertFalse(configuration.runtime.output_root.exists()) + + def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( + self, + ) -> None: + """Direct execution never pulls unrelated profile members into its supervised run.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core = root / "core" / "system" + core.mkdir(parents=True) + (root / "plugins").mkdir() + + (core / "first.py").write_text( + delayed_collector_source("first", 0), + encoding="utf-8", + ) + (core / "second.py").write_text( + delayed_collector_source("second", 0), + encoding="utf-8", + ) + + output = io.StringIO() + + with patch.object( + CLI, + "project_root", + return_value=root, + ), patch( + "sys.stdout", + output, + ): + self.assertEqual( + 0, + main([ + "collector", + "core.system.first", + "--acknowledge-authorization", + ]), + ) + + manifests = list( + (root / "output" / "data").glob("run-*/manifest.json") + ) + self.assertEqual(1, len(manifests)) + + manifest = json.loads( + manifests[0].read_text(encoding="utf-8") + ) + + self.assertEqual( + ["core.system.first"], + [record["id"] for record in manifest["collectors"]], + ) + self.assertIn("Status: succeeded", output.getvalue()) + + dependent = core / "dependent.py" + dependent.write_text( + delayed_collector_source( + "dependent", + 0, + dependencies=("core.system.second",), + ), + encoding="utf-8", + ) + + report = preflight(root) + plan = build_plan( + report, + RunRequest( + include=("core.system.dependent",), + selection_only=True, + acknowledge_authorization=True, + max_workers=1, + ), + ) + + self.assertEqual( + ["core.system.second", "core.system.dependent"], + self._collector_ids(plan.collectors), + ) + + def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work(self) -> None: + """Reruns require a finalized authentic-shaped manifest and explicit original collector IDs.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + manifest_path = root / "manifest.json" + collector_id = "core.system.system_info" + valid_manifest = { + "manifest_schema_version": 1, + "run_id": "run-" + "a" * 32, + "status": "succeeded", + "resolved_plan": [collector_id], + } + manifest_path.write_text(json.dumps(valid_manifest), encoding="utf-8") + parser = cli_methods.parser() + + with self.assertRaisesRegex(ValueError, "explicit --include"): + cli_methods.request(parser.parse_args(["run", "--rerun-from", str(manifest_path)]), 2) + with self.assertRaisesRegex(ValueError, "not present in the original"): + cli_methods.request( + parser.parse_args( + ["run", "--rerun-from", str(manifest_path), "--include", "core.system.other"] + ), + 2, + ) + manifest_path.write_text(json.dumps({**valid_manifest, "status": "running"}), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "finalized"): + cli_methods.request( + parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), + 2, + ) + for schema_version in (None, True, 2): + with self.subTest(manifest_schema_version=schema_version): + invalid_manifest = {**valid_manifest, "manifest_schema_version": schema_version} + manifest_path.write_text(json.dumps(invalid_manifest), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "unsupported schema_version"): + cli_methods.request( + parser.parse_args( + ["run", "--rerun-from", str(manifest_path), "--include", collector_id] + ), + 2, + ) + manifest_path.write_text("not json", encoding="utf-8") + with self.assertRaisesRegex(ValueError, "cannot be loaded"): + cli_methods.request( + parser.parse_args(["run", "--rerun-from", str(manifest_path), "--include", collector_id]), + 2, + ) + + def test_dependency_closure_and_plan_fingerprint_are_deterministic(self) -> None: + """Diamond dependencies resolve once and hash identically regardless of discovery order.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependencies = { + "a_root": (), + "b_left": ("core.system.a_root",), + "c_right": ("core.system.a_root",), + "z_target": ("core.system.c_right", "core.system.b_left"), + } + for filename, required in dependencies.items(): + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, 0.0, dependencies=required), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + request = RunRequest( + include=("core.system.z_target",), + rerun_from="run-" + "a" * 32, + acknowledge_authorization=True, + ) + first = build_plan(report, request) + reversed_plan = build_plan(PreflightReport(tuple(reversed(report.candidates))), request) + expected = ( + "core.system.a_root", + "core.system.b_left", + "core.system.c_right", + "core.system.z_target", + ) + + self.assertEqual(expected, tuple(self._collector_ids(first.collectors))) + self.assertEqual(expected, tuple(self._collector_ids(reversed_plan.collectors))) + self.assertEqual(first.fingerprint, reversed_plan.fingerprint) + self.assertEqual(64, len(first.fingerprint)) + + def test_planner_rejects_excluded_missing_and_sensitive_dependency_conflicts(self) -> None: + """Dependency expansion cannot override explicit exclusions or sensitive opt-in boundaries.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + target_id = "core.system.z_target" + (core_directory / "a_dependency.py").write_text( + delayed_collector_source("a_dependency", 0.0), + encoding="utf-8", + ) + (core_directory / "z_target.py").write_text( + delayed_collector_source("z_target", 0.0, dependencies=(dependency_id,)), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "explicitly excluded"): + build_plan(preflight(root), RunRequest(include=(target_id,), exclude=(dependency_id,))) + sensitive_source = delayed_collector_source("a_dependency", 0.0).replace( + "from logicytics import CollectorMetadata,", + "from logicytics import Capability, CollectorMetadata,", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SENSITIVE_FILES,),\n' + ' sensitive_data_categories=("credentials",),\n' + ' default_profiles=("deep",),', + ) + (core_directory / "a_dependency.py").write_text(sensitive_source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + with self.assertRaisesRegex(PlanError, "sensitive dependency"): + build_plan(report, + RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,))) + approved = build_plan( + report, + RunRequest( + include=(target_id, dependency_id), + approved_capabilities=(Capability.SENSITIVE_FILES,), + ), + ) + self.assertEqual([dependency_id, target_id], self._collector_ids(approved.collectors)) + (core_directory / "a_dependency.py").unlink() + with self.assertRaisesRegex(PlanError, "unavailable collector"): + build_plan(preflight(root), RunRequest(include=(target_id,))) + (core_directory / "a_dependency.py").write_text( + delayed_collector_source("a_dependency", 0.0, dependencies=(target_id,)), + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "dependency cycle"): + build_plan(preflight(root), RunRequest(include=(target_id,))) + + def test_capability_gate_requires_explicit_approval(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + secured_collector = COLLECTOR.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + 'supported_platforms=("win32",),', + 'supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,),', + ) + collector_path.write_text(secured_collector, encoding="utf-8") + report = preflight(root) + with self.assertRaises(PlanError): + build_plan(report, RunRequest()) + plan = build_plan( + report, + RunRequest(approved_capabilities=(Capability.FILESYSTEM_READ,)), + ) + self.assertEqual(1, len(plan.collectors)) + + def test_elevated_collectors_require_approval_and_administrator_privileges(self) -> None: + """Privilege-sensitive collection must fail closed before launching a worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + secured_collector = COLLECTOR.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + 'supported_platforms=("win32",),', + 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,), ' + 'privilege_level=PrivilegeLevel.ELEVATED,', + ) + collector_path.write_text(secured_collector, encoding="utf-8") + report = preflight(root) + with self.assertRaisesRegex(PlanError, "unapproved capabilities"): + build_plan(report, RunRequest()) + approved = RunRequest(approved_capabilities=(Capability.ELEVATED_PRIVILEGES,)) + for administrator_state in (False, None): + with self.subTest(administrator_state=administrator_state): + environment = EnvironmentReport(administrator_state, True, None) + with patch("logicytics.planner.inspect_environment", return_value=environment): + with self.assertRaisesRegex(PlanError, "administrator account"): + build_plan(report, approved) + with patch( + "logicytics.planner.inspect_environment", + return_value=EnvironmentReport(True, True, None), + ): + plan = build_plan(report, approved) + self.assertEqual(1, len(plan.collectors)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_preflight.py b/tests/test_preflight.py new file mode 100644 index 00000000..cb8dddc2 --- /dev/null +++ b/tests/test_preflight.py @@ -0,0 +1,674 @@ +from __future__ import annotations + +import io +import json +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from fixtures.collectors import COLLECTOR, plugin_collector_source, delayed_collector_source +from logicytics import ( + discovery, +) +from logicytics.cli import CLI, main +from logicytics.configuration import ( + load_config, +) +from logicytics.contracts import ( + Capability, + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.environment import EnvironmentReport +from logicytics.errors import PlanError, PreflightError +from logicytics.planner import build_plan +from logicytics.platform_adapters import ProcessAdapter +from logicytics.runtime import RunSupervisor + + +class PreflightTests(unittest.TestCase): + """Static/runtime preflight validation and quarantine behavior.""" + + def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(self) -> None: + """A plugin cannot silently inherit fields that affect consent or scheduling.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + plugin_path = root / "plugins" / "example_plugin.py" + plugin_path.parent.mkdir(parents=True) + source = plugin_collector_source().replace("SystemInfoCollector", "ExamplePluginCollector") + source = source.replace("core.system.system_info", "plugin.example_plugin") + plugin_path.write_text( + source.replace(" network_access=NetworkAccess.NONE,\n", ""), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn( + "plugin metadata must explicitly declare: network_access", + report.invalid[0].static_errors, + ) + + def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity( + self, + ) -> None: + """Only an exact validation context may reuse an isolated runtime probe.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text(COLLECTOR, encoding="utf-8") + + initial = preflight(root, configuration_hash="configuration-a") + self.assertEqual(1, len(initial.valid), initial.invalid) + + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, + ) as probe: + cached = preflight(root, configuration_hash="configuration-a") + self.assertEqual(1, len(cached.valid), cached.invalid) + probe.assert_not_called() + + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, + ) as probe: + invalidated = preflight(root, configuration_hash="configuration-b") + self.assertEqual(1, len(invalidated.valid), invalidated.invalid) + self.assertEqual(1, probe.call_count) + + def test_preflight_requires_exact_declared_artifact_media_types(self) -> None: + """A collector cannot disguise or omit the output contract used by registration.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text( + COLLECTOR.replace( + 'output_media_types=("text/plain",)', + 'output_media_types=("application/json",)', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertTrue( + any("include every" in error for error in report.invalid[0].static_errors), + report.invalid[0].static_errors, + ) + + collector_path.write_text( + COLLECTOR.replace(' output_media_types=("text/plain",),\n', ""), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertTrue( + any("explicitly declare" in error for error in report.invalid[0].static_errors), + report.invalid[0].static_errors, + ) + + def test_preflight_rejects_unregistered_collector_resource_classes(self) -> None: + """Invalid scheduling metadata blocks shipped collectors before any run starts.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n resource_class="disk_heavy",', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("resource_class", report.invalid[0].runtime_error or "") + diagnostic = report.invalid[0].diagnostics[0] + self.assertEqual("runtime.contract", diagnostic.rule) + self.assertEqual(str(collector_path), diagnostic.path) + assert diagnostic.line is not None + self.assertGreater(diagnostic.line, 1) + self.assertIn("resource_class", diagnostic.message) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest()) + + def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: + """A shipped collector cannot silently introduce sensitive default evidence.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text( + COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' sensitive_data_categories=("credentials",),', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("sensitive collectors", report.invalid[0].runtime_error or "") + with self.assertRaises(PreflightError): + build_plan(report, RunRequest()) + + def test_run_output_budget_bounds_concurrent_collector_evidence(self) -> None: + """Concurrent isolated workers must never exceed the configured run-wide cap.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "b_second"): + source = delayed_collector_source(filename, 0.1).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_output_bytes=4,', + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"runtime":{"maximum_run_output_bytes":7}}', + encoding="utf-8", + ) + configuration = load_config(root) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + + records = {record.id: record for record in outcome.manifest.collectors} + self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) + self.assertEqual("failed", records["core.system.b_second"].status) + self.assertTrue( + any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors) + ) + self.assertLessEqual(outcome.manifest.total_artifact_bytes, 7) + + def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: + """A completely consumed run quota prevents later workers from launching.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "b_second"): + source = delayed_collector_source(filename, 0.0).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_output_bytes=4,', + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + config_path = root / "logicytics.json" + config_path.write_text( + '{"schema_version":4,"runtime":{"maximum_run_output_bytes":4}}', + encoding="utf-8", + ) + configuration = load_config(root) + plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, configuration).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) + second = records["core.system.b_second"] + self.assertEqual("skipped", second.status) + self.assertIsNone(second.started_at) + self.assertTrue(any("maximum_run_output_bytes" in error for error in second.errors)) + self.assertEqual(4, outcome.manifest.total_artifact_bytes) + + def test_invalid_core_blocks_a_run_but_unselected_plugin_is_quarantined(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text('"""Invalid core collector."""\n', encoding="utf-8") + plugin_path = root / "plugins" / "broken_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + report = preflight(root) + self.assertEqual(2, len(report.invalid)) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest()) + + def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text(COLLECTOR, encoding="utf-8") + + plugin_path = root / "plugins" / "broken_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text( + '"""Invalid plugin collector."""\n', + encoding="utf-8", + ) + + report = preflight(root) + payload = report.to_dict() + + valid = payload["valid"] + assert isinstance(valid, list) + assert all(isinstance(item, dict) for item in valid) + + invalid = payload["invalid"] + assert isinstance(invalid, list) + + quarantined = payload["quarantined"] + assert isinstance(quarantined, list) + assert all(isinstance(item, dict) for item in quarantined) + + self.assertEqual( + ["core.system.system_info"], + [item["id"] for item in valid], + ) + self.assertEqual([], invalid) + self.assertEqual( + ["plugin.broken_plugin"], + [item["id"] for item in quarantined], + ) + + quarantined_item = quarantined[0] + + diagnostics = quarantined_item["diagnostics"] + assert isinstance(diagnostics, list) + assert all(isinstance(item, dict) for item in diagnostics) + + diagnostic = diagnostics[0] + + self.assertEqual(str(plugin_path), diagnostic["path"]) + self.assertEqual(1, diagnostic["line"]) + self.assertEqual("static.class_name", diagnostic["rule"]) + + message = diagnostic["message"] + assert isinstance(message, str) + + self.assertIn( + "exactly one public collector class", + message, + ) + + selected = report.to_dict( + selected_plugins=("plugin.broken_plugin",), + ) + + selected_quarantined = selected["quarantined"] + assert isinstance(selected_quarantined, list) + + selected_invalid = selected["invalid"] + assert isinstance(selected_invalid, list) + assert all(isinstance(item, dict) for item in selected_invalid) + + self.assertEqual([], selected_quarantined) + self.assertEqual( + ["plugin.broken_plugin"], + [item["id"] for item in selected_invalid], + ) + + enabled = report.to_dict(enable_plugins=True) + + enabled_invalid = enabled["invalid"] + assert isinstance(enabled_invalid, list) + assert all(isinstance(item, dict) for item in enabled_invalid) + + self.assertEqual( + ["plugin.broken_plugin"], + [item["id"] for item in enabled_invalid], + ) + + plan = build_plan( + report, + RunRequest(), + ) + + self.assertEqual( + ["core.system.system_info"], + [candidate.metadata.id for candidate in plan.collectors], + ) + + def test_invalid_selected_folder_or_enabled_plugin_blocks_planning(self) -> None: + """Folder-owned invalid plugins fail closed by logical ID and explicit enablement.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text(COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "broken_folder" / "main.py" + plugin_path.parent.mkdir(parents=True) + plugin_path.write_text('"""Invalid folder-owned plugin collector."""\n', encoding="utf-8") + report = preflight(root) + + self.assertEqual("plugin.broken_folder", report.invalid[0].selection_id) + build_plan(report, RunRequest()) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest(include=("plugin.broken_folder",))) + with self.assertRaises(PreflightError): + build_plan(report, RunRequest(enable_plugins=True)) + + def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(self) -> None: + """The public preflight report exposes actionable diagnostics and fail-closed selection.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_path = root / "core" / "system" / "system_info.py" + core_path.parent.mkdir(parents=True) + core_path.write_text(COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "broken_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + + for arguments, expected_exit, key in ( + (["preflight"], 0, "quarantined"), + (["preflight", "--include", "plugin.broken_plugin"], 2, "invalid"), + (["preflight", "--plugins"], 2, "invalid"), + ): + with self.subTest(arguments=arguments): + output = io.StringIO() + with patch.object(CLI, "project_root", return_value=root), patch( + "sys.stdout", + output, + ): + exit_code = main(arguments) + payload = json.loads(output.getvalue()) + self.assertEqual(expected_exit, exit_code) + self.assertEqual("plugin.broken_plugin", payload[key][0]["id"]) + self.assertEqual(str(plugin_path), payload[key][0]["diagnostics"][0]["path"]) + self.assertIn("rule", payload[key][0]["diagnostics"][0]) + + def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: + """Lifecycle annotations must match the strict collector contract exactly.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace("def collect(self, context: CollectorContext) -> CollectorResult:", + "def collect(self, context: CollectorContext) -> ValidationResult:"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) + diagnostic = next( + item for item in report.invalid[0].diagnostics + if item.message == "collect must return CollectorResult" + ) + source_lines = collector_path.read_text(encoding="utf-8").splitlines() + expected_line = next( + index for index, line in enumerate(source_lines, start=1) + if "def collect(self, context: CollectorContext) -> ValidationResult:" in line + ) + self.assertEqual(expected_line, diagnostic.line) + self.assertEqual("static.return_annotation", diagnostic.rule) + self.assertEqual(str(collector_path), diagnostic.path) + + def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: + """Optional estimates use the same strict typed contract as lifecycle methods.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + " def cleanup(self, context: CollectorContext) -> None:", + " def estimate(self, context: CollectorContext) -> ValidationResult:\n" + " \"\"\"Return an invalid estimate type for this test.\"\"\"\n" + " return ValidationResult(valid=True)\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("estimate must return CollectionEstimate", report.invalid[0].static_errors) + + def test_preflight_rejects_invalid_dependencies_contract(self) -> None: + """Optional dependency declarations must remain typed class-level metadata.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + " def cleanup(self, context: CollectorContext) -> None:", + " def dependencies(self) -> tuple[str, ...]:\n" + " \"\"\"Return an invalid instance-level dependency declaration.\"\"\"\n" + " return ()\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("dependencies must be a classmethod", report.invalid[0].static_errors) + + def test_preflight_rejects_dependency_declaration_mismatch(self) -> None: + """The planner-facing metadata and optional dependency method must agree.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + " def cleanup(self, context: CollectorContext) -> None:", + " @classmethod\n" + " def dependencies(cls) -> tuple[str, ...]:\n" + " \"\"\"Return a dependency absent from metadata for this test.\"\"\"\n" + " return ('core.system.missing',)\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("dependencies() must match metadata.dependencies", report.invalid[0].runtime_error or "") + + def test_preflight_rejects_malformed_validate_result(self) -> None: + """The isolated preflight probe must reject a non-ValidationResult response.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace("return ValidationResult(True)", "return CollectorResult.succeeded('invalid')"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("validate() must return ValidationResult", report.invalid[0].runtime_error or "") + + def test_preflight_rejects_validation_filesystem_process_network_and_environment_side_effects(self) -> None: + """No validation probe may mutate files, launch processes, open sockets, or alter its environment.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + outside = root / "outside.marker" + source = COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport os\nimport socket\nimport subprocess\n", + ) + attempts = ( + (f"Path({str(outside)!r}).write_text('unexpected', encoding='utf-8')", "open"), + ("(context.workspace / 'unexpected').mkdir()", "os.mkdir"), + ("subprocess.run(['python', '-c', 'pass'], check=False)", "subprocess.Popen"), + ("socket.socket()", "socket.__new__"), + ("os.environ['LOGICYTICS_PROBE_MUTATION'] = 'unexpected'", "os.putenv"), + ) + for statement, event in attempts: + with self.subTest(event=event): + collector_path.write_text( + source.replace( + " return ValidationResult(True)", + f" {statement}\n return ValidationResult(True)", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("validate() must not perform side effects", report.invalid[0].runtime_error or "") + self.assertIn(event, report.invalid[0].runtime_error or "") + self.assertFalse(outside.exists()) + + def test_preflight_rejects_validation_artifact_registration_and_exceptions(self) -> None: + """Artifact registration and ordinary validation exceptions block collection.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + attempts = ( + ("context.artifacts.register_file(context.workspace / 'evidence.txt')", "must not register artifacts"), + ("raise RuntimeError('unexpected validation failure')", "unexpected validation failure"), + ) + for statement, message in attempts: + with self.subTest(message=message): + collector_path.write_text( + COLLECTOR.replace( + " return ValidationResult(True)", + f" {statement}\n return ValidationResult(True)", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn(message, report.invalid[0].runtime_error or "") + + def test_preflight_rejects_a_hanging_validation_worker(self) -> None: + """A timed-out validation subprocess must quarantine its collector before launch.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + with patch.object( + discovery.process_adapter, + discovery.process_adapter.run.__name__, + side_effect=subprocess.TimeoutExpired( + ["validation-worker"], + timeout=10, + ), + ): + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("validation worker failed", report.invalid[0].runtime_error or "") + self.assertIn("timed out", report.invalid[0].runtime_error or "") + + def test_preflight_rejects_collector_print_calls(self) -> None: + """Collectors must emit structured events rather than write directly to stdout.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace("return CollectorResult.succeeded(\"test artifact created\", (artifact,))", + "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("collectors must not print; use structured progress or logging", + report.invalid[0].static_errors) + + def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> None: + """Collection-like work must not run before a worker has isolated the collector.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + "class SystemInfoCollector(CoreCollector):", + "class SystemInfoCollector(CoreCollector, type(open('unexpected.txt'))):", + ).replace( + " @classmethod\n def metadata", + " marker = input('unexpected prompt')\n\n @classmethod\n def metadata", + ).replace( + "def validate(self, context: CollectorContext) -> ValidationResult:", + "def validate(self, context: CollectorContext = open('another.txt')) -> ValidationResult:", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + errors = report.invalid[0].static_errors + self.assertTrue(any("forbidden import-time call: type" in error for error in errors)) + self.assertTrue(any("forbidden import-time call: input" in error for error in errors)) + self.assertTrue(any("forbidden import-time call: open" in error for error in errors)) + + def test_preflight_probe_uses_a_restricted_environment(self) -> None: + """Validation probes expose only documented preflight variables to collectors.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + source = COLLECTOR.replace( + "from pathlib import Path", + "import os\nfrom pathlib import Path", + ).replace( + " return ValidationResult(True)", + " if os.environ.get('LOGICYTICS_VALIDATION') != '1':\n" + " return ValidationResult(False, reasons=('validation flag missing',))\n" + " if os.environ.get('LOGICYTICS_TEST_SECRET') is not None:\n" + " return ValidationResult(False, reasons=('caller environment leaked',))\n" + " return ValidationResult(True)", + 1, + ) + collector_path.write_text(source, encoding="utf-8") + with patch.dict("os.environ", {"LOGICYTICS_TEST_SECRET": "must-not-leak"}): + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + + def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> None: + """An opt-in privileged plugin must not affect an ordinary core-only plan.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + plugin_path = root / "plugins" / "admin_plugin.py" + plugin_path.parent.mkdir(parents=True) + source = plugin_collector_source() + source = source.replace("SystemInfoCollector", "AdminPluginCollector") + source = source.replace("core.system.system_info", "plugin.admin_plugin") + source = source.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + " capabilities=(),", + " capabilities=(Capability.ELEVATED_PRIVILEGES,),", + ).replace( + " privilege_level=PrivilegeLevel.STANDARD,", + " privilege_level=PrivilegeLevel.ELEVATED,", + ) + plugin_path.write_text(source, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + with patch("logicytics.planner.inspect_environment") as inspect: + plan = build_plan(report, RunRequest()) + self.assertEqual((), plan.collectors) + inspect.assert_not_called() + enabled = RunRequest( + enable_plugins=True, + approved_capabilities=(Capability.ELEVATED_PRIVILEGES,), + ) + with patch( + "logicytics.planner.inspect_environment", + return_value=EnvironmentReport(False, True, None), + ): + with self.assertRaisesRegex(PlanError, "administrator account"): + build_plan(report, enabled) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 00000000..92a42a54 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest +from dataclasses import FrozenInstanceError +from pathlib import Path +from unittest.mock import patch + +from fixtures.collectors import COLLECTOR, plugin_collector_source +from logicytics import ( + load_configuration, + open_artifact, + plan_run, + query_run, + read_artifact, + run_collection, +) +from logicytics.cli import CLI, main +from logicytics.contracts import ( + RunRequest, + RunStatus, +) +from logicytics.errors import ArtifactError, PlanError, PreflightError + + +class PublicApiTests(unittest.TestCase): + """Public planning, execution, query, and artifact API behavior.""" + + def test_public_package_import_does_not_start_runtime_or_create_files(self) -> None: + """Importing contracts exposes lazy application services without host or disk side effects.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + project_root = Path(__file__).resolve().parents[1] + environment = dict(os.environ) + environment["PYTHONPATH"] = str(project_root) + probe = ( + "import pathlib, sys; " + "before = tuple(pathlib.Path.cwd().iterdir()); " + "import logicytics; " + "assert 'logicytics.runtime' not in sys.modules; " + "assert {'load_configuration', 'plan_run', 'run_collection', 'query_run', 'read_artifact'}" + ".issubset(logicytics.__all__); " + "assert tuple(pathlib.Path.cwd().iterdir()) == before" + ) + result = subprocess.run( + [sys.executable, "-c", probe], + cwd=root, + env=environment, + capture_output=True, + check=False, + text=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual([], list(root.iterdir())) + + def test_public_planning_is_read_only_bounded_and_keeps_plugins_opt_in(self) -> None: + """The application planner validates configuration and extensions without starting work.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text(COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "example_plugin.py" + plugin_path.parent.mkdir() + plugin_path.write_text( + plugin_collector_source() + .replace("SystemInfoCollector", "ExamplePluginCollector") + .replace("core.system.system_info", "plugin.example_plugin"), + encoding="utf-8", + ) + (root / "settings.json").write_text( + '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', + encoding="utf-8", + ) + configuration = load_configuration(root, "settings.json") + standard = plan_run(root, RunRequest(max_workers=1), configuration=configuration) + selected = plan_run( + root, + RunRequest(max_workers=1, include=("plugin.example_plugin",)), + configuration=configuration, + ) + + self.assertEqual(["core.system.system_info"], [item.metadata.id for item in standard.collectors]) + self.assertEqual( + ["core.system.system_info", "plugin.example_plugin"], + [item.metadata.id for item in selected.collectors], + ) + with self.assertRaisesRegex(PlanError, "maximum_workers"): + plan_run(root, RunRequest(max_workers=3), configuration=configuration) + with self.assertRaisesRegex(PlanError, "either configuration or config_path"): + plan_run(root, RunRequest(max_workers=1), configuration=configuration, config_path="settings.json") + plugin_path.write_text('"""Invalid optional plugin."""\n', encoding="utf-8") + quarantined = plan_run(root, RunRequest(max_workers=1), configuration=configuration) + self.assertEqual(["core.system.system_info"], [item.metadata.id for item in quarantined.collectors]) + with self.assertRaises(PreflightError): + plan_run( + root, + RunRequest(max_workers=1, include=("plugin.example_plugin",)), + configuration=configuration, + ) + collector_path.write_text('"""Invalid shipped core collector."""\n', encoding="utf-8") + with self.assertRaises(PreflightError): + plan_run(root, RunRequest(max_workers=1), configuration=configuration) + self.assertFalse(configuration.runtime.output_root.exists()) + + def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> None: + """The complete public lifecycle retains isolation and immutable manifest-backed status.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + configuration = load_configuration(root) + request = RunRequest(max_workers=1, acknowledge_authorization=True) + outcome = run_collection(root, request, configuration=configuration) + snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) + + self.assertEqual(RunStatus.SUCCEEDED, snapshot.status, outcome.manifest.package) + self.assertEqual(outcome.run_directory, snapshot.run_directory) + self.assertEqual(outcome.manifest_path, snapshot.manifest_path) + self.assertEqual(1, + json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"]) + self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) + self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) + collector = snapshot.collectors[0] + self.assertIsNotNone(collector.started_at) + self.assertIsNotNone(collector.finished_at) + duration_seconds = collector.duration_seconds + if duration_seconds is None: + self.fail("completed collector must expose duration_seconds") + self.assertGreaterEqual(duration_seconds, 0) + self.assertEqual("test artifact created", collector.summary) + self.assertEqual((), collector.errors) + self.assertIsNone(collector.failure) + self.assertEqual(1, len(snapshot.artifacts)) + self.assertEqual( + b"ok" + os.linesep.encode("ascii"), + read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration), + ) + with self.assertRaises(FrozenInstanceError): + snapshot.status = RunStatus.FAILED + with self.assertRaises(FrozenInstanceError): + collector.status = "failed" + + def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration( + self, + ) -> None: + """Callers can inspect redacted lifecycle timing and actionable failure details without parsing manifests.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + + collector_path.write_text( + COLLECTOR.replace( + ' output = context.workspace / "system.txt"', + ' raise RuntimeError("fixture lifecycle failure")\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + + outcome = run_collection( + root, + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + snapshot = query_run(root, outcome.manifest.run_id) + collector = snapshot.collectors[0] + + self.assertEqual("failed", collector.status) + duration_seconds = collector.duration_seconds + if duration_seconds is None: + self.fail("failed collector must expose duration_seconds") + + self.assertGreaterEqual(duration_seconds, 0) + self.assertIn("worker crashed", collector.summary) + self.assertTrue( + any( + "fixture lifecycle failure" in error + for error in collector.errors + ) + ) + failure = collector.failure + if failure is None: + self.fail("failed collector must expose structured failure details") + self.assertEqual("core.system.system_info", failure.collector_id) + self.assertEqual("collect", failure.operation) + self.assertIn("fixture lifecycle failure", failure.platform_error) + self.assertTrue(failure.remediation) + self.assertTrue(failure.retry_safe) + + collector_path.write_text( + COLLECTOR, + encoding="utf-8", + ) + + output = io.StringIO() + + with patch.object( + CLI, + "project_root", + return_value=root, + ), patch( + "sys.stdout", + output, + ), patch( + "builtins.input", + return_value="", + ) as final_prompt: + exit_code = main( + [ + "run", + "--default", + "--interactive", + "--acknowledge-authorization", + ] + ) + + self.assertEqual(0, exit_code, output.getvalue()) + final_prompt.assert_called_once_with("Press Enter to exit...") + self.assertIn("Collectors:", output.getvalue()) + self.assertIn( + "core.system.system_info status=succeeded duration_seconds=", + output.getvalue(), + ) + self.assertIn( + "summary=test artifact created", + output.getvalue(), + ) + + def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links(self) -> None: + """Persisted status lookup cannot leave its configured run or trust a forged manifest.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) + original = outcome.manifest_path.read_text(encoding="utf-8") + + for invalid in ("../outside", "run-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "run-short"): + with self.subTest(run_id=invalid): + with self.assertRaisesRegex(PlanError, "canonical run identifier"): + query_run(root, invalid) + payload = json.loads(original) + payload["run_id"] = "run-" + "0" * 32 + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "identity"): + query_run(root, outcome.manifest.run_id) + invalid_manifests = ( + ("manifest_schema_version", 2, "unsupported run manifest schema_version"), + ("manifest_schema_version", True, "unsupported run manifest schema_version"), + ("status", [], "unsupported run status"), + ("resolved_plan", ["core.system.forged"], "resolved plan"), + ("total_artifact_bytes", 999, "total_artifact_bytes"), + ("requested_at", "not-a-timestamp", "requested_at"), + ("finished_at", None, "finished_at"), + ) + for field, value, message in invalid_manifests: + with self.subTest(field=field): + payload = json.loads(original) + payload[field] = value + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + query_run(root, outcome.manifest.run_id) + payload = json.loads(original) + payload.pop("manifest_schema_version") + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "unsupported run manifest schema_version"): + query_run(root, outcome.manifest.run_id) + invalid_collector_fields = ( + ("started_at", "not-a-timestamp", "collector started_at"), + ("finished_at", None, "require finished_at"), + ("duration_seconds", -1, "duration_seconds"), + ("summary", "", "summary"), + ("errors", [""], "errors"), + ( + "failure", + { + "collector_id": "core.system.system_info", + "operation": "collect", + "platform_error": "forged", + "remediation": "forged", + "retry_safe": True, + }, + "must match failed status", + ), + ) + for field, value, message in invalid_collector_fields: + with self.subTest(collector_field=field): + payload = json.loads(original) + payload["collectors"][0][field] = value + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, message): + query_run(root, outcome.manifest.run_id) + payload = json.loads(original) + payload["collectors"][0]["id"] = "../outside" + payload["resolved_plan"] = ["../outside"] + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "collector record"): + query_run(root, outcome.manifest.run_id) + outcome.manifest_path.write_text( + '{"run_id":"' + outcome.manifest.run_id + '","run_id":"duplicate"}', + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "duplicate run manifest key"): + query_run(root, outcome.manifest.run_id) + outcome.manifest_path.write_text(original, encoding="utf-8") + outside = root / "outside-manifest.json" + outside.write_text(original, encoding="utf-8") + outcome.manifest_path.unlink() + outcome.manifest_path.symlink_to(outside) + with self.assertRaisesRegex(PlanError, "escapes its run-owned directory"): + query_run(root, outcome.manifest.run_id) + + def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_access(self) -> None: + """Only exact, bounded, manifest-cataloged bytes from the producing collector may be read.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text(COLLECTOR, encoding="utf-8") + outcome = run_collection(root, RunRequest(max_workers=1, acknowledge_authorization=True)) + artifact = outcome.manifest.artifact_list()[0] + stored = outcome.run_directory / "artifacts" / artifact.relative_path + original = stored.read_bytes() + manifest_source = outcome.manifest_path.read_text(encoding="utf-8") + + with patch("logicytics.api.os.startfile") as startfile: + self.assertEqual( + stored, + open_artifact(root, outcome.manifest.run_id, artifact.id), + ) + startfile.assert_called_once_with(stored) + + with self.assertRaisesRegex(ArtifactError, "canonical"): + read_artifact(root, outcome.manifest.run_id, "../outside") + with self.assertRaisesRegex(ArtifactError, "not registered"): + read_artifact(root, outcome.manifest.run_id, "artifact." + "0" * 32) + for invalid_limit in (True, 0, 67_108_865): + with self.subTest(maximum_bytes=invalid_limit): + with self.assertRaisesRegex(ArtifactError, "maximum_bytes"): + read_artifact(root, outcome.manifest.run_id, artifact.id, maximum_bytes=invalid_limit) + with self.assertRaisesRegex(ArtifactError, "bounded read limit"): + read_artifact(root, outcome.manifest.run_id, artifact.id, maximum_bytes=1) + stored.write_bytes(b"bad") + with self.assertRaisesRegex(ArtifactError, "SHA-256 verification"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + with self.assertRaisesRegex(ArtifactError, "size"): + open_artifact(root, outcome.manifest.run_id, artifact.id) + stored.write_bytes(original) + payload = json.loads(manifest_source) + payload["artifact_catalog"][0]["producer_status"] = "failed" + outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") + with self.assertRaisesRegex(PlanError, "artifact catalog"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + outcome.manifest_path.write_text(manifest_source, encoding="utf-8") + outside = root / "outside-evidence.txt" + outside.write_bytes(original) + stored.unlink() + stored.symlink_to(outside) + with self.assertRaisesRegex(ArtifactError, "collector-owned store"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + stored.unlink() + owner = stored.parent + owner.rmdir() + redirected_owner = root / "redirected-owner" + redirected_owner.mkdir() + (redirected_owner / stored.name).write_bytes(original) + owner.symlink_to(redirected_owner, target_is_directory=True) + with self.assertRaisesRegex(ArtifactError, "collector-owned store"): + read_artifact(root, outcome.manifest.run_id, artifact.id) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 00000000..c32c68d0 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,737 @@ +from __future__ import annotations + +import ctypes +import json +import os +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest.mock import patch + +from fixtures.collectors import COLLECTOR, delayed_collector_source +from logicytics.configuration import ( + default_config, +) +from logicytics.contracts import ( + Capability, + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class RuntimeTests(unittest.TestCase): + """Worker lifecycle, retries, failures, timeouts, cleanup, and cancellation.""" + + def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: + """A failed prerequisite must contain failure and prevent dependent execution.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + (core_directory / "a_dependency.py").write_text( + delayed_collector_source("a_dependency", 0.0, fail=True), + encoding="utf-8", + ) + (core_directory / "b_dependent.py").write_text( + delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("failed", records[dependency_id].status) + dependent = records["core.system.b_dependent"] + self.assertEqual("skipped", dependent.status) + self.assertIsNone(dependent.started_at) + self.assertTrue(any(dependency_id in error for error in dependent.errors)) + self.assertEqual(["core.system.b_dependent"], outcome.manifest.skipped_collectors) + self.assertEqual( + {dependency_id, "core.system.b_dependent"}, + {error["collector_id"] for error in outcome.manifest.errors}, + ) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertEqual(["core.system.b_dependent"], packaged["skipped_collectors"]) + self.assertIn("Skipped collectors: 1", summary) + + def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: + """An isolated collector crash must produce a durable failed run outcome.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + 'output = context.workspace / "system.txt"\n' + ' output.write_text("ok\\n", encoding="utf-8")\n' + ' artifact = context.artifacts.register_file(output, media_type="text/plain")\n' + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + 'raise RuntimeError("intentional test collector failure")', + ), + encoding="utf-8", + ) + report = preflight(root) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.status.value) + record = outcome.manifest.collectors[0] + self.assertEqual("failed", record.status) + self.assertTrue(any("RuntimeError" in error for error in record.errors)) + self.assertIsNotNone(outcome.manifest.package) + package_path = Path(outcome.manifest.package["path"]) + self.assertTrue(package_path.is_file()) + with zipfile.ZipFile(package_path) as archive: + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertIn("Status: failed", summary) + self.assertIn("Reasons:", summary) + self.assertIn("RuntimeError", summary) + self.assertIn(f"Started: {record.started_at}", summary) + self.assertIn(f"Finished: {record.finished_at}", summary) + + def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> None: + """An explicitly retryable collector may recover before any evidence is registered.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' maximum_retries=1,\n' + ' retry_delay_seconds=0.01,', + ).replace( + ' output = context.workspace / "system.txt"', + ' marker = context.workspace / "attempt.marker"\n' + ' if not marker.exists():\n' + ' marker.write_text("first attempt failed", encoding="utf-8")\n' + ' raise RuntimeError("temporary collector failure")\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + + record = outcome.manifest.collectors[0] + self.assertEqual("succeeded", record.status, record.errors) + self.assertIsNone(record.failure) + self.assertEqual(2, record.attempt_count) + self.assertEqual(1, len(record.retry_history)) + self.assertEqual("collect", record.retry_history[0]["failure"]["operation"]) + self.assertTrue(record.retry_history[0]["failure"]["retry_safe"]) + self.assertTrue( + any("temporary collector failure" in error for error in record.retry_history[0]["errors"]) + ) + self.assertIsInstance(record.worker_pid, int) + self.assertIsInstance(record.retry_history[0]["worker_pid"], int) + self.assertNotEqual(record.worker_pid, record.retry_history[0]["worker_pid"]) + self.assertEqual("failed", record.retry_history[0]["termination_reason"]) + self.assertEqual("completed", record.termination_reason) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertEqual(2, packaged_record["attempt_count"]) + self.assertEqual(1, len(packaged_record["retry_history"])) + self.assertIsNone(packaged_record["failure"]) + self.assertTrue(packaged_record["retry_history"][0]["failure"]["retry_safe"]) + self.assertIn("Attempts: 2", summary) + + def test_retry_policy_stops_after_declared_attempt_limit(self) -> None: + """A repeatedly failing collector may not exceed its explicitly declared retry cap.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_retries=1,', + ).replace( + ' output = context.workspace / "system.txt"', + ' raise RuntimeError("persistent collector failure")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertEqual(2, record.attempt_count) + self.assertEqual(1, len(record.retry_history)) + self.assertTrue(any("persistent collector failure" in error for error in record.errors)) + + def test_failed_collector_with_registered_evidence_is_never_retried(self) -> None: + """Evidence-producing failures must not be repeated or overwrite forensic state.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n maximum_retries=2,', + ).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("failure after evidence registration")', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertEqual(1, record.attempt_count) + self.assertEqual([], record.retry_history) + self.assertTrue((outcome.run_directory / "artifacts" / "core_system_system_info" / "system.txt").is_file()) + self.assertEqual(1, len(record.artifacts)) + self.assertEqual(1, len(outcome.manifest.artifact_list())) + + failure = record.failure + assert failure is not None + + self.assertEqual("core.system.system_info", failure["collector_id"]) + self.assertEqual("collect", failure["operation"]) + + platform_error = failure["platform_error"] + assert isinstance(platform_error, str) + + self.assertIn( + "failure after evidence registration", + platform_error, + ) + + self.assertFalse(failure["retry_safe"]) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) + packaged = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertEqual(record.failure, packaged["failure"]) + self.assertIn("Failed operation: collect", summary) + self.assertIn("Retry safe: false", summary) + self.assertIn("Remediation:", summary) + + def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results(self) -> None: + """A failed process finalizes once, preserves evidence, and cannot stop its neighbor.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + failing_source = delayed_collector_source("a_failed", 0.0).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("collection failed after evidence registration")', + ).replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' (context.workspace / "cleanup.marker").write_text("cleaned", encoding="utf-8")\n' + ' (context.temporary_directory / "scratch.txt").write_text("scratch", encoding="utf-8")', + ) + (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + failed = records["core.system.a_failed"] + succeeded = records["core.system.z_independent"] + workspace = outcome.run_directory / "collectors" / "core_system_a_failed" + + self.assertEqual("partial", outcome.manifest.status.value) + self.assertEqual("failed", failed.status) + self.assertEqual("succeeded", succeeded.status) + self.assertEqual("cleaned", (workspace / "cleanup.marker").read_text(encoding="utf-8")) + self.assertFalse((workspace / "tmp").exists()) + self.assertEqual(1, len(failed.artifacts)) + self.assertIn("RuntimeError", "\n".join(failed.errors)) + self.assertEqual("process", failed.isolation_mode) + self.assertIsInstance(failed.worker_pid, int) + self.assertEqual("failed", failed.termination_reason) + self.assertEqual("completed", succeeded.termination_reason) + self.assertNotEqual(failed.worker_pid, succeeded.worker_pid) + with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + packaged = { + item["id"]: item + for item in json.loads(archive.read("metadata/manifest.json"))["collectors"] + } + summary = archive.read("reports/summary.txt").decode("utf-8") + self.assertIn("evidence/derived/core_system_a_failed/system.txt", archive.namelist()) + self.assertEqual("failed", packaged["core.system.a_failed"]["status"]) + self.assertEqual("process", packaged["core.system.a_failed"]["isolation_mode"]) + self.assertEqual(failed.worker_pid, packaged["core.system.a_failed"]["worker_pid"]) + self.assertEqual("succeeded", packaged["core.system.z_independent"]["status"]) + self.assertIn("Isolation: process", summary) + self.assertIn("Termination: failed", summary) + self.assertIn("collection failed after evidence registration", summary) + + def test_cleanup_failure_preserves_original_collector_crash_and_independent_results(self) -> None: + """Cleanup errors must be reported without replacing the original failure.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + failing_source = delayed_collector_source("a_failed", 0.0).replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("original collection failure")', + ).replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' raise ValueError("secondary cleanup failure")', + ) + (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + errors = "\n".join(records["core.system.a_failed"].errors) + + self.assertEqual("failed", records["core.system.a_failed"].status) + self.assertIn("RuntimeError: original collection failure", errors) + self.assertIn("collector cleanup failed: ValueError: secondary cleanup failure", errors) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertEqual("partial", outcome.manifest.status.value) + + def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> None: + """A failed finalizer turns success into failure without discarding registered bytes.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + + collector_path.write_text( + COLLECTOR.replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' raise RuntimeError("finalizer failed after collecting evidence")', + ), + encoding="utf-8", + ) + + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertEqual("collector cleanup failed", record.summary) + self.assertEqual(1, len(record.artifacts)) + self.assertIn( + "finalizer failed after collecting evidence", + "\n".join(record.errors), + ) + + failure = record.failure + assert failure is not None + + self.assertEqual("cleanup", failure["operation"]) + + remediation = failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn("cleanup", remediation) + + retry_safe = failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertFalse(retry_safe) + + package = outcome.manifest.package + assert package is not None + assert "path" in package + + with zipfile.ZipFile(Path(package["path"])) as archive: + self.assertIn( + "evidence/derived/core_system_system_info/system.txt", + archive.namelist(), + ) + + def test_collector_timeout_preserves_independent_worker_results(self) -> None: + """A timed-out worker is terminated without cancelling unrelated collection.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + + timeout_source = delayed_collector_source("a_timeout", 2.0).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' timeout_seconds=1,', + ) + + (core_directory / "a_timeout.py").write_text( + timeout_source, + encoding="utf-8", + ) + + (core_directory / "z_independent.py").write_text( + delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + + report = preflight(root) + self.assertEqual((), report.invalid) + + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + records = { + record.id: record + for record in outcome.manifest.collectors + } + + timeout_record = records["core.system.a_timeout"] + + self.assertEqual("failed", timeout_record.status) + self.assertTrue( + any("timeout" in error for error in timeout_record.errors) + ) + self.assertEqual( + "timeout_exceeded", + timeout_record.termination_reason, + ) + + timeout_failure = timeout_record.failure + assert timeout_failure is not None + + retry_safe = timeout_failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertTrue(retry_safe) + + remediation = timeout_failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn("timeout", remediation) + + independent_record = records["core.system.z_independent"] + + self.assertEqual("succeeded", independent_record.status) + self.assertIsNone(independent_record.failure) + self.assertEqual("partial", outcome.manifest.status.value) + + @unittest.skipUnless(os.name == "nt", "Windows collector subprocess-tree containment") + def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peers(self) -> None: + """A timed-out worker cannot leave its child alive or terminate independent collectors.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + source = delayed_collector_source("a_tree", 0.0).replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SUBPROCESS,),\n timeout_seconds=2,', + ).replace( + ' output = context.workspace / "system.txt"', + ' child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])\n' + ' (context.workspace / "child.pid").write_text(str(child.pid), encoding="ascii")\n' + ' sleep(5)\n' + ' output = context.workspace / "system.txt"', + ) + (core_directory / "a_tree.py").write_text(source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + child_pid = int( + (outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text( + encoding="ascii" + ) + ) + handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, child_pid) + if handle: + try: + exit_code = ctypes.c_ulong() + self.assertTrue(ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))) + self.assertNotEqual(259, exit_code.value, "collector subprocess survived worker termination") + finally: + ctypes.windll.kernel32.CloseHandle(handle) + + self.assertEqual("failed", records["core.system.a_tree"].status) + self.assertEqual("timeout_exceeded", records["core.system.a_tree"].termination_reason) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + + @unittest.skipUnless(os.name == "nt", "Windows working-set enforcement test") + def test_collector_memory_limit_preserves_independent_worker_results(self) -> None: + """A memory-limit violation terminates only the offending isolated worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + + limited_source = delayed_collector_source("a_limited", 2.0).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' maximum_memory_bytes=1,', + ) + + (core_directory / "a_limited.py").write_text( + limited_source, + encoding="utf-8", + ) + + (core_directory / "z_independent.py").write_text( + delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + + report = preflight(root) + self.assertEqual((), report.invalid) + + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + records = { + record.id: record + for record in outcome.manifest.collectors + } + + limited = records["core.system.a_limited"] + + self.assertEqual("failed", limited.status) + + peak_memory_bytes = limited.peak_memory_bytes + assert peak_memory_bytes is not None + + self.assertGreater(peak_memory_bytes, 1) + self.assertTrue( + any( + "maximum_memory_bytes=1" in error + for error in limited.errors + ) + ) + self.assertEqual( + "memory_limit_exceeded", + limited.termination_reason, + ) + + limited_failure = limited.failure + assert limited_failure is not None + + self.assertEqual( + "collect", + limited_failure["operation"], + ) + + remediation = limited_failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn( + "memory limit", + remediation, + ) + + retry_safe = limited_failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertTrue(retry_safe) + + independent = records["core.system.z_independent"] + + self.assertEqual("succeeded", independent.status) + self.assertEqual("partial", outcome.manifest.status.value) + + def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: + """Keyboard cancellation must affect only this run and preserve its partial report.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + + collector_path.write_text( + COLLECTOR, + encoding="utf-8", + ) + + report = preflight(root) + + plan = build_plan( + report, + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + supervisor = RunSupervisor( + root, + default_config(root), + ) + + with patch.object( + supervisor, + supervisor._supervise.__name__, + side_effect=KeyboardInterrupt, + ): + outcome = supervisor.run(plan) + + self.assertEqual("cancelled", outcome.manifest.status.value) + self.assertTrue(outcome.manifest.cancellation_requested) + + record = outcome.manifest.collectors[0] + + self.assertEqual("cancelled", record.status) + self.assertEqual("run cancelled by user", record.summary) + self.assertTrue((outcome.run_directory / ".cancelled").is_file()) + + package = outcome.manifest.package + assert package is not None + assert "path" in package + + package_path = Path(package["path"]) + + self.assertTrue(package_path.is_file()) + + with zipfile.ZipFile(package_path) as archive: + summary = archive.read( + "reports/summary.txt" + ).decode("utf-8") + + packaged_manifest = json.loads( + archive.read("metadata/manifest.json") + ) + + self.assertTrue(packaged_manifest["cancellation_requested"]) + self.assertIn("Status: cancelled", summary) + self.assertIn("Cancellation requested: true", summary) + self.assertIn("Summary: run cancelled by user", summary) + self.assertIn("Started: not started", summary) + + finished_at = record.finished_at + assert finished_at is not None + + self.assertIn( + f"Finished: {finished_at}", + summary, + ) + + def test_worker_runs_typed_prepare_collect_finalize_and_cleanup_lifecycle(self) -> None: + """Every accepted collector executes the complete typed lifecycle inside its worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + source = COLLECTOR.replace( + " def collect(self, context: CollectorContext) -> CollectorResult:", + " def prepare(self, context: CollectorContext) -> ValidationResult:\n" + " \"\"\"Prepare worker-local lifecycle state.\"\"\"\n" + " context.settings['prepared'] = 'yes'\n" + " return ValidationResult(True)\n\n" + " def collect(self, context: CollectorContext) -> CollectorResult:", + ).replace( + " def cleanup(self, context: CollectorContext) -> None:", + " def finalize(self, context: CollectorContext, result: CollectorResult) -> CollectorResult:\n" + " \"\"\"Finalize the typed result before it crosses the worker boundary.\"\"\"\n" + " if context.settings.get('prepared') != 'yes':\n" + " raise RuntimeError('prepare phase did not run')\n" + " return CollectorResult.succeeded('finalized lifecycle', result.artifacts)\n\n" + " def cleanup(self, context: CollectorContext) -> None:", + ) + collector_path.write_text(source, encoding="utf-8") + + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + plan = build_plan( + report, + RunRequest(max_workers=1, acknowledge_authorization=True), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + self.assertEqual("finalized lifecycle", record.summary) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py new file mode 100644 index 00000000..f61c7478 --- /dev/null +++ b/tests/test_scheduler.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from fixtures.collectors import delayed_collector_source +from logicytics.cli import cli_methods +from logicytics.configuration import ( + default_config, +) +from logicytics.contracts import ( + ResourceClass, + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class SchedulerTests(unittest.TestCase): + """Parallel scheduling, resource classes, and dependency ordering.""" + + def test_parallel_completion_preserves_deterministic_manifest_order(self) -> None: + """Collector completion order must not reorder the preflighted execution plan.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + + (core_directory / "a_slow.py").write_text( + delayed_collector_source("a_slow", 0.6), + encoding="utf-8", + ) + (core_directory / "z_fast.py").write_text( + delayed_collector_source("z_fast", 0.0), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest(max_workers=2, acknowledge_authorization=True), + ) + planned_ids = [candidate.metadata.id for candidate in plan.collectors] + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) + fast_finished = records["core.system.z_fast"].finished_at + slow_finished = records["core.system.a_slow"].finished_at + assert fast_finished is not None and slow_finished is not None + self.assertLess(fast_finished, slow_finished) + + def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: + """First-class CLI policies determine real sequential versus bounded worker overlap.""" + for execution_mode, expect_overlap in (("--sequential", False), ("--parallel", True)): + with self.subTest(execution_mode=execution_mode), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "z_second"): + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, 0.3), + encoding="utf-8", + ) + arguments = cli_methods.parser().parse_args( + ["run", execution_mode, "--acknowledge-authorization"] + ) + request = request(arguments, default_workers=2) + report = preflight(root) + self.assertEqual((), report.invalid) + outcome = RunSupervisor(root, default_config(root)).run(build_plan(report, request)) + records = {record.id: record for record in outcome.manifest.collectors} + first = records["core.system.a_first"] + second = records["core.system.z_second"] + + self.assertEqual("succeeded", first.status, first.errors) + self.assertEqual("succeeded", second.status, second.errors) + if expect_overlap: + assert second.started_at is not None and first.finished_at is not None + self.assertLess(second.started_at, first.finished_at) + else: + assert first.finished_at is not None and second.started_at is not None + self.assertLessEqual(first.finished_at, second.started_at) + + def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: + """A collector declaring parallel_safe=False must run alone between bounded workers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + fixtures = ( + ("a_parallel", 0.3, True), + ("m_serial", 0.2, False), + ("z_parallel", 0.0, True), + ) + for filename, delay, parallel_safe in fixtures: + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, delay, parallel_safe=parallel_safe), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest(max_workers=3, acknowledge_authorization=True), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + first = records["core.system.a_parallel"] + serial = records["core.system.m_serial"] + last = records["core.system.z_parallel"] + assert first.finished_at is not None and serial.started_at is not None + self.assertLessEqual(first.finished_at, serial.started_at) + assert serial.finished_at is not None and last.started_at is not None + self.assertLessEqual(serial.finished_at, last.started_at) + + def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: + """Disk, network, and registry resource conflicts each serialize their owners.""" + for resource_class in ( + ResourceClass.DISK_HEAVY, + ResourceClass.NETWORK_HEAVY, + ResourceClass.REGISTRY_SENSITIVE, + ): + with self.subTest(resource_class=resource_class), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + for filename in ("a_first", "z_second"): + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, 0.15, resource_class=resource_class), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + first = records["core.system.a_first"] + second = records["core.system.z_second"] + + self.assertEqual("succeeded", first.status, first.errors) + self.assertEqual("succeeded", second.status, second.errors) + assert first.finished_at is not None and second.started_at is not None + self.assertLessEqual(first.finished_at, second.started_at) + + def test_independent_resource_classes_preserve_bounded_parallelism(self) -> None: + """Different noninteractive resource owners can overlap in isolated workers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + fixtures = ( + ("a_disk", ResourceClass.DISK_HEAVY), + ("z_network", ResourceClass.NETWORK_HEAVY), + ) + for filename, resource_class in fixtures: + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, 0.35, resource_class=resource_class), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("succeeded", records["core.system.a_disk"].status) + self.assertEqual("succeeded", records["core.system.z_network"].status) + network_started = records["core.system.z_network"].started_at + disk_finished = records["core.system.a_disk"].finished_at + assert network_started is not None and disk_finished is not None + self.assertLess(network_started, disk_finished) + + def test_interactive_resource_class_runs_without_any_worker_overlap(self) -> None: + """Interactive collectors require an exclusive deterministic scheduler window.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + fixtures = ( + ("a_disk", ResourceClass.DISK_HEAVY), + ("m_interactive", ResourceClass.INTERACTIVE), + ("z_general", ResourceClass.GENERAL), + ) + for filename, resource_class in fixtures: + (core_directory / f"{filename}.py").write_text( + delayed_collector_source(filename, 0.15, resource_class=resource_class), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=3, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + disk_finished = records["core.system.a_disk"].finished_at + interactive_started = records["core.system.m_interactive"].started_at + interactive_finished = records["core.system.m_interactive"].finished_at + general_started = records["core.system.z_general"].started_at + assert disk_finished is not None and interactive_started is not None + assert interactive_finished is not None and general_started is not None + self.assertLessEqual(disk_finished, interactive_started) + self.assertLessEqual(interactive_finished, general_started) + + def test_dependencies_finish_before_dependents_start(self) -> None: + """Topological order must become an execution barrier under bounded parallelism.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + (core_directory / "a_dependency.py").write_text( + delayed_collector_source("a_dependency", 0.3), + encoding="utf-8", + ) + (core_directory / "b_dependent.py").write_text( + delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + dependency = records[dependency_id] + dependent = records["core.system.b_dependent"] + self.assertEqual("succeeded", dependent.status, dependent.errors) + self.assertIsNotNone(dependency.finished_at) + self.assertIsNotNone(dependent.started_at) + assert dependency.finished_at is not None and dependent.started_at is not None + self.assertLessEqual(dependency.finished_at, dependent.started_at) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py new file mode 100644 index 00000000..cc4a7caf --- /dev/null +++ b/tests/test_worker_security.py @@ -0,0 +1,392 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +from fixtures.collectors import COLLECTOR, delayed_collector_source +from logicytics.configuration import ( + default_config, +) +from logicytics.contracts import ( + Capability, + RunRequest, +) +from logicytics.discovery import preflight +from logicytics.planner import build_plan +from logicytics.runtime import RunSupervisor + + +class WorkerSecurityTests(unittest.TestCase): + """Worker isolation and explicit capability enforcement.""" + + def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> None: + """Direct collector writes outside its private evidence roots fail without affecting peers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + attempts = ( + "context.workspace.parent / 'z_independent' / 'compromised.txt'", + f"Path({str(root / 'repository-compromised.txt')!r})", + ) + for target in attempts: + with self.subTest(target=target): + source = delayed_collector_source("a_attacker", 0.0).replace( + ' output = context.workspace / "system.txt"', + f" ({target}).write_text('compromised', encoding='utf-8')\n" + ' output = context.workspace / "system.txt"', + ) + (core_directory / "a_attacker.py").write_text(source, encoding="utf-8") + (core_directory / "z_independent.py").write_text( + delayed_collector_source("z_independent", 0.0), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=2, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + self.assertEqual("failed", records["core.system.a_attacker"].status) + self.assertIn("escapes its private workspace", "\n".join(records["core.system.a_attacker"].errors)) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertFalse((root / "repository-compromised.txt").exists()) + self.assertFalse( + (outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists() + ) + + def test_collector_cannot_mutate_its_process_environment(self) -> None: + """A collector must not change process environment or working-directory policy.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace("from pathlib import Path\n", "from pathlib import Path\nimport os\n").replace( + ' output = context.workspace / "system.txt"', + ' os.environ["LOGICYTICS_COLLECTOR_MUTATION"] = "unexpected"\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn("os.putenv", "\n".join(outcome.manifest.collectors[0].errors)) + + def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities(self) -> None: + """Privileged platform access must be declared in metadata, not merely imported.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + + base = COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\n" + "import socket\n" + "import subprocess\n" + "import sys\n" + "import winreg\n", + ) + + attempts = ( + ( + "subprocess.run([sys.executable, '-c', 'pass'], check=False)", + "subprocess capability", + ), + ( + "socket.socket()", + "network capability", + ), + ( + "winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Software')", + "registry_read capability", + ), + ) + + for operation, message in attempts: + with self.subTest(operation=operation): + collector_path.write_text( + base.replace( + ' output = context.workspace / "system.txt"', + f' {operation}\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + record = outcome.manifest.collectors[0] + + self.assertEqual("failed", record.status) + self.assertIn( + message, + "\n".join(record.errors), + ) + + failure = record.failure + assert failure is not None + + self.assertEqual("access", failure["operation"]) + + remediation = failure["remediation"] + if not isinstance(remediation, str): + self.fail( + f"remediation must be str, got {type(remediation).__name__}" + ) + + self.assertIn("capability", remediation) + + retry_safe = failure["retry_safe"] + if not isinstance(retry_safe, bool): + self.fail( + f"retry_safe must be bool, got {type(retry_safe).__name__}" + ) + + self.assertFalse(retry_safe) + + def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities(self) -> None: + """External evidence reads require filesystem access plus every applicable sensitive grant.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + ordinary = root / "outside.txt" + ordinary.write_text("ordinary evidence", encoding="utf-8") + browser = root / "Chrome" / "User Data" / "Default" / "Cookies" + browser.parent.mkdir(parents=True) + browser.write_text("browser evidence", encoding="utf-8") + private_key = root / ".ssh" / "id_ed25519" + private_key.parent.mkdir() + private_key.write_text("private evidence", encoding="utf-8") + cases = ( + (ordinary, (), "filesystem_read capability"), + (ordinary, (Capability.FILESYSTEM_READ,), None), + (browser, (Capability.FILESYSTEM_READ,), "browser_data capability"), + (browser, (Capability.FILESYSTEM_READ, Capability.BROWSER_DATA), "sensitive_files capability"), + ( + private_key, + (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + "private_keys capability", + ), + ( + private_key, + (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), + None, + ), + ) + for evidence, capabilities, denied in cases: + with self.subTest(evidence=evidence.name, capabilities=capabilities): + declared = ", ".join(f"Capability.{capability.name}" for capability in capabilities) + if len(capabilities) == 1: + declared += "," + collector_path.write_text( + COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + f' supported_platforms=("win32",),\n' + f' capabilities=({declared}),', + ).replace( + ' output = context.workspace / "system.txt"', + f" Path({str(evidence)!r}).read_text(encoding='utf-8')\n" + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + approved_capabilities=capabilities, + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + record = outcome.manifest.collectors[0] + if denied is None: + self.assertEqual("succeeded", record.status, record.errors) + else: + self.assertEqual("failed", record.status) + self.assertIn(denied, "\n".join(record.errors)) + + def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self) -> None: + """General network approval must not silently authorize raw packet capture.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport socket\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.NETWORK,),\n' + ' network_access=NetworkAccess.LOCAL,', + ).replace( + ' output = context.workspace / "system.txt"', + ' socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + approved_capabilities=(Capability.NETWORK,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("failed", outcome.manifest.collectors[0].status) + self.assertIn("packet_capture capability", "\n".join(outcome.manifest.collectors[0].errors)) + + def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> None: + """A declared subprocess capability remains usable after explicit request approval.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n capabilities=(Capability.SUBPROCESS,),', + ).replace( + ' output = context.workspace / "system.txt"', + ' subprocess.run([sys.executable, "-c", "pass"], check=True)\n' + ' output = context.workspace / "system.txt"', + ), + encoding="utf-8", + ) + plan = build_plan( + preflight(root), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + self.assertEqual("succeeded", outcome.manifest.collectors[0].status) + + def test_worker_blocks_application_update_power_and_peer_collector_commands_without_stopping_peers(self) -> None: + """Subprocess approval cannot escape the collector role or affect an independent worker.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + peer_path = core_directory / "z_independent.py" + peer_path.write_text(delayed_collector_source("z_independent", 0.0), encoding="utf-8") + protected_configuration = root / "logicytics.json" + attempts = ( + ([sys.executable, "-m", "logicytics", "preflight"], "main_application"), + ([sys.executable, "-m", "pip", "--version"], "repository_or_package_management"), + (["git", "--version"], "repository_or_package_management"), + (["shutdown", "/?"], "system_power"), + (["cmd", "/c", "shutdown", "/?"], "system_power"), + ( + [ + sys.executable, + "-c", + f"from pathlib import Path; Path({str(protected_configuration)!r}).write_text('bad')", + ], + "configuration_mutation", + ), + ([sys.executable, str(peer_path)], "collector_launch"), + ) + for command, category in attempts: + with self.subTest(command=command): + attacker = delayed_collector_source("a_attacker", 0.0).replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nfrom logicytics import Capability\n", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + ' capabilities=(Capability.SUBPROCESS,),', + ).replace( + ' output = context.workspace / "system.txt"', + f" subprocess.run({command!r}, check=True)\n" + ' output = context.workspace / "system.txt"', + ) + (core_directory / "a_attacker.py").write_text(attacker, encoding="utf-8") + report = preflight(root) + self.assertEqual((), report.invalid) + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + approved_capabilities=(Capability.SUBPROCESS,), + ), + ) + outcome = RunSupervisor(root, default_config(root)).run(plan) + records = {record.id: record for record in outcome.manifest.collectors} + + self.assertEqual("failed", records["core.system.a_attacker"].status) + self.assertIn( + f"collector subprocess command is prohibited: {category}", + "\n".join(records["core.system.a_attacker"].errors), + ) + self.assertEqual("succeeded", records["core.system.z_independent"].status) + self.assertEqual("partial", outcome.manifest.status.value) + self.assertFalse(protected_configuration.exists()) + + def test_preflight_rejects_collector_imports_of_application_and_orchestration_services(self) -> None: + """Collectors may import public contracts but never the main application control surface.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + attempts = ( + "import logicytics.cli", + "from logicytics import run_collection", + "from logicytics import packaging", + "import logicytics\nlogicytics.run_collection", + "import logicytics\nlogicytics.configuration", + "from logicytics.api import query_run", + ) + for statement in attempts: + with self.subTest(statement=statement): + collector_path.write_text( + COLLECTOR.replace("from pathlib import Path\n", f"from pathlib import Path\n{statement}\n"), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.invalid)) + self.assertIn("forbidden application import", "\n".join(report.invalid[0].static_errors)) + diagnostic = next( + item for item in report.invalid[0].diagnostics + if "forbidden application import" in item.message + ) + self.assertEqual("static.engine_boundary", diagnostic.rule) + + +if __name__ == "__main__": + unittest.main() From 2242b54ba0a33b62292d6555debb75b735bfae6b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 02:15:48 +0300 Subject: [PATCH 230/316] fix: incorrect typing/weak typing via static analyzer also small type fixes; also fixed patch name type bugs with some files (wip) Signed-off-by: DefinetlyNotAI --- core/integration/legacy_code_outputs.py | 73 +- core/process/memory_map.py | 47 +- core/ssh/ssh_backup.py | 96 ++- logicytics/__init__.py | 2 +- logicytics/configuration.py | 17 +- logicytics/discovery.py | 297 +++++++-- logicytics/modes.py | 111 +++- logicytics/packaging.py | 5 +- logicytics/platform_adapters.py | 107 ++- logicytics/runtime.py | 326 ++++++--- logicytics/validation_worker.py | 20 +- tests/test_collector_cancellation.py | 641 ++++++++++++++---- tests/test_platform_adapters.py | 36 +- tests/test_runtime.py | 6 + tests/test_shipped_collectors.py | 844 +++++++++++++++++++----- tests/test_windows_integration.py | 45 +- 16 files changed, 2108 insertions(+), 565 deletions(-) diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py index 0be890f6..5614cb19 100644 --- a/core/integration/legacy_code_outputs.py +++ b/core/integration/legacy_code_outputs.py @@ -69,15 +69,31 @@ def validate(self, context: CollectorContext) -> ValidationResult: def collect(self, context: CollectorContext) -> CollectorResult: """Stream allowlisted generated files into the private workspace and artifact store.""" if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before legacy CODE import") + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before legacy CODE import", + ) + code_root = self._code_root().resolve() candidates: list[Path] = [] - for path in sorted(filesystem_adapter.recursive(code_root)): + + for path in sorted( + filesystem_adapter.recursive(code_root), + key=lambda candidate: candidate.as_posix(), + ): if context.is_cancelled: - return CollectorResult.cancelled("cancelled during legacy CODE discovery") + return CollectorResult.cancelled( + "cancelled during legacy CODE discovery" + ) + relative = path.relative_to(code_root) - if any(part.casefold() in _EXCLUDED_DIRECTORIES for part in relative.parts): + + if any( + part.casefold() in _EXCLUDED_DIRECTORIES + for part in relative.parts + ): continue + try: if ( not path.is_file() @@ -89,55 +105,90 @@ def collect(self, context: CollectorContext) -> CollectorResult: continue except OSError: continue + candidates.append(path) + if len(candidates) >= _MAXIMUM_FILES: break artifacts = [] errors: list[str] = [] + for source in candidates: if context.is_cancelled: - return CollectorResult.cancelled("cancelled during legacy CODE import", tuple(artifacts)) + return CollectorResult.cancelled( + "cancelled during legacy CODE import", + tuple(artifacts), + ) + relative = source.relative_to(code_root) destination = context.workspace / "legacy_code" / relative destination.parent.mkdir(parents=True, exist_ok=True) + temporary = destination.with_suffix(destination.suffix + ".tmp") + try: - with source.open("rb") as source_stream, temporary.open("xb") as destination_stream: + with ( + source.open("rb") as source_stream, + temporary.open("xb") as destination_stream, + ): while block := source_stream.read(1024 * 1024): if context.is_cancelled: raise InterruptedError("run cancellation was requested") + destination_stream.write(block) + temporary.replace(destination) + artifacts.append( context.artifacts.register_file( destination, media_type="application/octet-stream", evidence_kind=EvidenceKind.RAW, - transformations=("imported from historical CODE output",), + transformations=( + "imported from historical CODE output", + ), ) ) + except InterruptedError: temporary.unlink(missing_ok=True) destination.unlink(missing_ok=True) - return CollectorResult.cancelled("cancelled during legacy CODE import", tuple(artifacts)) + + return CollectorResult.cancelled( + "cancelled during legacy CODE import", + tuple(artifacts), + ) + except OSError as error: temporary.unlink(missing_ok=True) errors.append(f"{relative.as_posix()}: {error}") + if not artifacts and not errors: - return CollectorResult.skipped("no generated legacy CODE evidence matched the import policy") + return CollectorResult.skipped( + "no generated legacy CODE evidence matched the import policy" + ) + if errors: return CollectorResult.partial( "legacy CODE evidence imported with file-level failures", tuple(artifacts), errors=tuple(errors), ) + context.report_progress( "legacy_code_outputs_finished", imported_files=len(artifacts), - bytes_written=sum(artifact.size_bytes for artifact in artifacts), + bytes_written=sum( + artifact.size_bytes + for artifact in artifacts + ), + ) + + return CollectorResult.succeeded( + "legacy CODE evidence imported", + tuple(artifacts), ) - return CollectorResult.succeeded("legacy CODE evidence imported", tuple(artifacts)) def cleanup(self, context: CollectorContext) -> None: """Remove only an unpublished temporary copy left by an interrupted import.""" diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 1e2c82ef..5e6d3e61 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -51,15 +51,48 @@ def metadata(cls) -> CollectorMetadata: def validate(self, context: CollectorContext) -> ValidationResult: """Validate cancellation state and bounded region settings.""" if context.is_cancelled: - return ValidationResult(False, reasons=("run cancellation was requested",)) + return ValidationResult( + False, + reasons=("run cancellation was requested",), + ) + + def setting_int(name: str, default: int) -> int: + value = context.settings.get(name, default) + + if isinstance(value, bool): + raise ValueError(f"{name} must be an integer") + + if isinstance(value, (int, str, bytes, bytearray)): + return int(value) + + raise ValueError(f"{name} must be an integer") + try: - maximum = int(context.settings.get("max_regions", 5_000)) - output_limit = int(context.settings.get("output_limit_bytes", 64 * 1024 * 1024)) - safety_margin = int(context.settings.get("disk_safety_margin_bytes", 100 * 1024 * 1024)) + maximum = setting_int("max_regions", 5_000) + output_limit = setting_int( + "output_limit_bytes", + 64 * 1024 * 1024, + ) + safety_margin = setting_int( + "disk_safety_margin_bytes", + 100 * 1024 * 1024, + ) except (TypeError, ValueError): - return ValidationResult(False, reasons=("memory-map limits must be integers",)) - if not 1 <= maximum <= 100_000 or not 1_024 <= output_limit <= 64 * 1024 * 1024 or safety_margin < 0: - return ValidationResult(False, reasons=("invalid memory-map region, output, or safety limits",)) + return ValidationResult( + False, + reasons=("memory-map limits must be integers",), + ) + + if ( + not 1 <= maximum <= 100_000 + or not 1_024 <= output_limit <= 64 * 1024 * 1024 + or safety_margin < 0 + ): + return ValidationResult( + False, + reasons=("invalid memory-map region, output, or safety limits",), + ) + return ValidationResult(True) def collect(self, context: CollectorContext) -> CollectorResult: diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index 2a53eaef..bf3a8775 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -3,6 +3,7 @@ from __future__ import annotations import zipfile +from pathlib import Path from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ ValidationResult @@ -38,31 +39,58 @@ def validate(self, context: CollectorContext) -> ValidationResult: def collect(self, context: CollectorContext) -> CollectorResult: """Archive bounded regular files from .ssh while preserving relative paths.""" if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before SSH backup") + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before SSH backup", + ) + ssh_directory = filesystem_adapter.home() / ".ssh" + try: directory_exists = ssh_directory.is_dir() except OSError as error: - return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.SKIPPED, + "the current user's .ssh directory is inaccessible", + errors=(str(error),), + ) + if not directory_exists: - return CollectorResult(CollectorStatus.SKIPPED, "the current user has no .ssh directory") + return CollectorResult( + CollectorStatus.SKIPPED, + "the current user has no .ssh directory", + ) + context.report_progress("ssh_backup_started") + archive = context.workspace / "ssh_backup.zip" source_bytes = 0 archived_files = 0 skipped_files = 0 cancelled = False - with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as output: + + with zipfile.ZipFile( + archive, + "w", + compression=zipfile.ZIP_DEFLATED, + ) as output: try: - candidates = sorted(filesystem_adapter.recursive(ssh_directory)) + candidates: list[Path] = sorted( + (Path(path) for path in filesystem_adapter.recursive(ssh_directory)), + key=lambda path: path.as_posix(), + ) except OSError as error: - return CollectorResult(CollectorStatus.SKIPPED, "the current user's .ssh directory is inaccessible", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.SKIPPED, + "the current user's .ssh directory is inaccessible", + errors=(str(error),), + ) + for candidate in candidates: if context.is_cancelled: cancelled = True break + try: is_file = candidate.is_file() is_symlink = candidate.is_symlink() @@ -70,36 +98,70 @@ def collect(self, context: CollectorContext) -> CollectorResult: except OSError: skipped_files += 1 continue + if not is_file or is_symlink: continue - if size > MAX_FILE_BYTES or source_bytes + size > MAX_ARCHIVE_SOURCE_BYTES: + + if ( + size > MAX_FILE_BYTES + or source_bytes + size > MAX_ARCHIVE_SOURCE_BYTES + ): skipped_files += 1 continue - output.write(candidate, arcname=candidate.relative_to(ssh_directory).as_posix()) + + output.write( + candidate, + arcname=candidate.relative_to(ssh_directory).as_posix(), + ) + if context.is_cancelled: cancelled = True break + source_bytes += size archived_files += 1 + if cancelled: archive.unlink(missing_ok=True) - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during SSH backup") + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled during SSH backup", + ) + if archived_files == 0: - return CollectorResult(CollectorStatus.SKIPPED, "no SSH files met the bounded backup policy") + return CollectorResult( + CollectorStatus.SKIPPED, + "no SSH files met the bounded backup policy", + ) + if context.is_cancelled: archive.unlink(missing_ok=True) - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before SSH-backup registration") + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before SSH-backup registration", + ) + artifact = context.artifacts.register_file( archive, media_type="application/zip", evidence_kind=EvidenceKind.RAW, - transformations=("archived from the current user's SSH directory",), + transformations=( + "archived from the current user's SSH directory", + ), ) + context.report_progress( - "ssh_backup_finished", archived_files=archived_files, skipped_files=skipped_files, - source_bytes=source_bytes, bytes_written=artifact.size_bytes, + "ssh_backup_finished", + archived_files=archived_files, + skipped_files=skipped_files, + source_bytes=source_bytes, + bytes_written=artifact.size_bytes, + ) + + return CollectorResult.succeeded( + "SSH directory backup collected", + (artifact,), ) - return CollectorResult.succeeded("SSH directory backup collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: """Leave archive removal to the isolated collector workspace lifecycle.""" diff --git a/logicytics/__init__.py b/logicytics/__init__.py index bc6299fe..0c80d083 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -66,5 +66,5 @@ def __getattr__(name: str): "plan_run", "query_run", "read_artifact", - "run_collection", + "run_collection" ] diff --git a/logicytics/configuration.py b/logicytics/configuration.py index d82a0701..c9510d75 100644 --- a/logicytics/configuration.py +++ b/logicytics/configuration.py @@ -62,7 +62,10 @@ class CollectorSettingRule: maximum: int | float = 0 -_COLLECTOR_SETTING_SCHEMAS = MappingProxyType({ +_COLLECTOR_SETTING_SCHEMAS: Mapping[ + str, + Mapping[str, CollectorSettingRule], +] = MappingProxyType({ "core.network.bandwidth_sample": { "sample_count": CollectorSettingRule("integer", 1, 10), "interval_seconds": CollectorSettingRule("number", 0.1, 60), @@ -88,8 +91,16 @@ class CollectorSettingRule: }, "core.process.memory_map": { "max_regions": CollectorSettingRule("integer", 1, 100_000), - "output_limit_bytes": CollectorSettingRule("integer", 1_024, 64 * 1024 * 1024), - "disk_safety_margin_bytes": CollectorSettingRule("integer", 0, MAXIMUM_RUN_OUTPUT_BYTES), + "output_limit_bytes": CollectorSettingRule( + "integer", + 1_024, + 64 * 1024 * 1024, + ), + "disk_safety_margin_bytes": CollectorSettingRule( + "integer", + 0, + MAXIMUM_RUN_OUTPUT_BYTES, + ), "dump_directory": CollectorSettingRule("workspace_path"), }, }) diff --git a/logicytics/discovery.py b/logicytics/discovery.py index 8f300aa2..79cec911 100644 --- a/logicytics/discovery.py +++ b/logicytics/discovery.py @@ -13,7 +13,7 @@ from pathlib import Path from subprocess import TimeoutExpired -from logicytics.contracts import CONTRACT_VERSION, Capability, CollectorKind, CollectorMetadata +from logicytics.contracts import CONTRACT_VERSION, Capability, CollectorKind, CollectorMetadata, Specialty from logicytics.platform_adapters import process_adapter _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") @@ -432,99 +432,183 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: return candidate -def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidate) -> None: +def _validate_class_shape( + class_node: ast.ClassDef, + candidate: CollectorCandidate, +) -> None: """Enforce the small, documented public API allowed on a collector class.""" if ast.get_docstring(class_node) is None: candidate.static_errors.append("collector class requires a docstring") - methods = { - item.name: item - for item in class_node.body - if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and not item.name.startswith("_") - } + + methods: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + + for item in class_node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): + if not item.name.startswith("_"): + methods[item.name] = item + allowed = { - "metadata", "validate", "prepare", "collect", "finalize", "cleanup", "estimate", "dependencies", + "metadata", + "validate", + "prepare", + "collect", + "finalize", + "cleanup", + "estimate", + "dependencies", } metadata_calls: list[ast.Call] = [] - for node in ast.walk(methods["metadata"]): - if not isinstance(node, ast.Call): - continue + metadata_method = methods.get("metadata") + if metadata_method is not None: + for node in ast.walk(metadata_method): + if not isinstance(node, ast.Call): + continue - is_metadata_constructor = ( - isinstance(node.func, ast.Name) - and node.func.id == "CollectorMetadata" - ) or ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "CollectorMetadata" - ) + is_metadata_constructor = ( + isinstance(node.func, ast.Name) + and node.func.id == "CollectorMetadata" + ) or ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "CollectorMetadata" + ) - if is_metadata_constructor: - metadata_calls.append(node) + if is_metadata_constructor: + metadata_calls.append(node) - if len(metadata_calls) != 1: + if metadata_method is not None and len(metadata_calls) != 1: candidate.static_errors.append( "metadata must construct one CollectorMetadata object directly" ) - if candidate.kind is CollectorKind.PLUGIN and "metadata" in methods: + if candidate.kind is CollectorKind.PLUGIN and metadata_method is not None: required_plugin_fields = { - "capabilities", "privilege_level", "sensitive_data_categories", "network_access", - "estimated_cost", "timeout_seconds", "maximum_output_bytes", "output_media_types", + "capabilities", + "privilege_level", + "sensitive_data_categories", + "network_access", + "estimated_cost", + "timeout_seconds", + "maximum_output_bytes", + "output_media_types", "minimum_contract_version", } + if len(metadata_calls) == 1: - declared_fields = {keyword.arg for keyword in metadata_calls[0].keywords if keyword.arg is not None} + metadata_call = metadata_calls[0] + + declared_fields = { + keyword.arg + for keyword in metadata_call.keywords + if keyword.arg is not None + } + missing_fields = sorted(required_plugin_fields - declared_fields) + if missing_fields: candidate.static_errors.append( - f"plugin metadata must explicitly declare: {', '.join(missing_fields)}" + "plugin metadata must explicitly declare: " + f"{', '.join(missing_fields)}" ) - if "collect" in methods and len(metadata_calls) == 1: + + collect_method = methods.get("collect") + + if collect_method is not None and len(metadata_calls) == 1: + metadata_call = metadata_calls[0] + declared_keyword = next( - (keyword for keyword in metadata_calls[0].keywords if keyword.arg == "output_media_types"), + ( + keyword + for keyword in metadata_call.keywords + if keyword.arg == "output_media_types" + ), None, ) + if declared_keyword is None: - candidate.static_errors.append("metadata must explicitly declare output_media_types") + candidate.static_errors.append( + "metadata must explicitly declare output_media_types" + ) else: try: - declared_media_types = set(ast.literal_eval(declared_keyword.value)) + evaluated_media_types = ast.literal_eval(declared_keyword.value) + declared_media_types = set(evaluated_media_types) except (TypeError, ValueError): - candidate.static_errors.append("metadata output_media_types must be a literal tuple") + candidate.static_errors.append( + "metadata output_media_types must be a literal tuple" + ) else: registered_media_types: set[str] = set() - lifecycle_methods = [methods["collect"]] - if "finalize" in methods: - lifecycle_methods.append(methods["finalize"]) + + lifecycle_methods: list[ + ast.FunctionDef | ast.AsyncFunctionDef + ] = [collect_method] + + finalize_method = methods.get("finalize") + if finalize_method is not None: + lifecycle_methods.append(finalize_method) + for method in lifecycle_methods: for call in ast.walk(method): - if not isinstance(call, ast.Call) or not ( - isinstance(call.func, ast.Attribute) and call.func.attr == "register_file" + if not isinstance(call, ast.Call): + continue + + if not ( + isinstance(call.func, ast.Attribute) + and call.func.attr == "register_file" ): continue + media_keyword = next( - (keyword for keyword in call.keywords if keyword.arg == "media_type"), + ( + keyword + for keyword in call.keywords + if keyword.arg == "media_type" + ), None, ) + if media_keyword is None: - registered_media_types.add("application/octet-stream") - elif isinstance(media_keyword.value, ast.Constant) and isinstance( - media_keyword.value.value, str + registered_media_types.add( + "application/octet-stream" + ) + elif ( + isinstance(media_keyword.value, ast.Constant) + and isinstance(media_keyword.value.value, str) ): - registered_media_types.add(media_keyword.value.value) + registered_media_types.add( + media_keyword.value.value + ) else: candidate.static_errors.append( - f"register_file media_type must be a literal string (line {call.lineno})" + "register_file media_type must be a literal " + f"string (line {call.lineno})" ) - if not registered_media_types.issubset(declared_media_types): + + if not registered_media_types.issubset( + declared_media_types + ): candidate.static_errors.append( - "metadata output_media_types must include every registered artifact type" + "metadata output_media_types must include every " + "registered artifact type" ) + unknown = sorted(set(methods) - allowed) + if unknown: - candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") - required = {"metadata", "validate", "collect", "cleanup"} + candidate.static_errors.append( + "unsupported public collector methods: " + f"{', '.join(unknown)}" + ) + + required = { + "metadata", + "validate", + "collect", + "cleanup", + } + expected_returns = { "metadata": "CollectorMetadata", "validate": "ValidationResult", @@ -535,42 +619,108 @@ def _validate_class_shape(class_node: ast.ClassDef, candidate: CollectorCandidat "dependencies": "tuple[str,...]", "cleanup": "None", } + missing = sorted(required - set(methods)) + if missing: - candidate.static_errors.append(f"missing required collector methods: {', '.join(missing)}") + candidate.static_errors.append( + f"missing required collector methods: {', '.join(missing)}" + ) + for name, method in methods.items(): if ast.get_docstring(method) is None: - candidate.static_errors.append(f"{name} requires a docstring") + candidate.static_errors.append( + f"{name} requires a docstring" + ) + if method.returns is None: - candidate.static_errors.append(f"{name} requires a return type annotation") - elif name in expected_returns and ast.unparse(method.returns).replace(" ", "") != expected_returns[name]: - candidate.static_errors.append(f"{name} must return {expected_returns[name]}") + candidate.static_errors.append( + f"{name} requires a return type annotation" + ) + elif name in expected_returns: + actual_return = ast.unparse(method.returns).replace(" ", "") + expected_return = expected_returns[name] + + if actual_return != expected_return: + candidate.static_errors.append( + f"{name} must return {expected_return}" + ) + parameters = method.args.args + expected_parameter_count = ( - 1 if name in {"metadata", "dependencies"} else 3 if name == "finalize" else 2 + 1 + if name in {"metadata", "dependencies"} + else 3 + if name == "finalize" + else 2 ) + if len(parameters) != expected_parameter_count: - candidate.static_errors.append(f"{name} has an invalid parameter count") + candidate.static_errors.append( + f"{name} has an invalid parameter count" + ) continue - expected_first = "cls" if name in {"metadata", "dependencies"} else "self" + + expected_first = ( + "cls" + if name in {"metadata", "dependencies"} + else "self" + ) + if parameters[0].arg != expected_first: - candidate.static_errors.append(f"{name} must begin with {expected_first}") + candidate.static_errors.append( + f"{name} must begin with {expected_first}" + ) + if name in {"metadata", "dependencies"}: - if not any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in - method.decorator_list): - candidate.static_errors.append(f"{name} must be a classmethod") + is_classmethod = any( + isinstance(decorator, ast.Name) + and decorator.id == "classmethod" + for decorator in method.decorator_list + ) + + if not is_classmethod: + candidate.static_errors.append( + f"{name} must be a classmethod" + ) + + continue + + context_parameter = parameters[1] + + if context_parameter.annotation is None: + context_annotation = None else: - context_parameter = parameters[1] - annotation = ast.unparse(context_parameter.annotation).rsplit(".", 1)[ - -1] if context_parameter.annotation else None - if context_parameter.arg != "context" or annotation != "CollectorContext": - candidate.static_errors.append(f"{name} must accept an annotated context parameter") - if name == "finalize": - result_parameter = parameters[2] - result_annotation = ast.unparse(result_parameter.annotation).rsplit(".", 1)[ - -1] if result_parameter.annotation else None - if result_parameter.arg != "result" or result_annotation != "CollectorResult": - candidate.static_errors.append("finalize must accept an annotated result parameter") + context_annotation = ast.unparse( + context_parameter.annotation + ).rsplit(".", 1)[-1] + + if ( + context_parameter.arg != "context" + or context_annotation != "CollectorContext" + ): + candidate.static_errors.append( + f"{name} must accept an annotated context parameter" + ) + + if name == "finalize": + result_parameter = parameters[2] + + if result_parameter.annotation is None: + result_annotation = None + else: + result_annotation = ast.unparse( + result_parameter.annotation + ).rsplit(".", 1)[-1] + + if ( + result_parameter.arg != "result" + or result_annotation != "CollectorResult" + ): + candidate.static_errors.append( + "finalize must accept an annotated result parameter" + ) def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: @@ -606,13 +756,20 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> except (KeyError, TypeError, ValueError) as error: candidate.runtime_error = f"invalid validation response: {error}" return + + specialty = ( + metadata.specialty.value + if isinstance(metadata.specialty, Specialty) + else metadata.specialty + ) + if metadata.minimum_contract_version != CONTRACT_VERSION: candidate.runtime_error = "collector contract version is unsupported" elif metadata.timeout_seconds < 1 or metadata.maximum_output_bytes < 1: candidate.runtime_error = "collector must declare positive timeout and output limits" elif not metadata.id.startswith(f"{candidate.kind.value}."): candidate.runtime_error = "collector ID must start with its owner kind" - elif candidate.kind is CollectorKind.CORE and metadata.specialty.value != candidate.path.parent.name: + elif candidate.kind is CollectorKind.CORE and specialty != candidate.path.parent.name: candidate.runtime_error = "core collector specialty must match its parent folder" elif candidate.kind is CollectorKind.CORE and metadata.id != ( f"core.{candidate.path.parent.name}.{candidate.path.stem}" diff --git a/logicytics/modes.py b/logicytics/modes.py index 3460efbe..fb66648b 100644 --- a/logicytics/modes.py +++ b/logicytics/modes.py @@ -2,15 +2,39 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import dataclass from enum import Enum from types import MappingProxyType -from typing import Iterable, Mapping +from typing import Iterable, TypedDict, Mapping from logicytics.contracts import CollectorKind from logicytics.discovery import CollectorCandidate +class ModeMatrixCollector(TypedDict): + id: str + kind: str + valid: bool + modes: list[str] + manual_only: bool + execution_type: str + validation_errors: list[str] + + +class ModeMatrixMode(TypedDict): + name: str + description: str + strategy: str + legacy_aliases: list[str] + collector_ids: list[str] + + +class ModeMatrix(TypedDict): + schema_version: int + modes: list[ModeMatrixMode] + collectors: list[ModeMatrixCollector] + + class ExecutionStrategy(str, Enum): """Worker scheduling policy owned by an execution mode.""" @@ -123,7 +147,9 @@ def _candidate_modes(candidate: CollectorCandidate) -> tuple[str, ...]: return tuple(selected) -def mode_matrix(candidates: Iterable[CollectorCandidate] = ()) -> dict[str, object]: +def mode_matrix( + candidates: Iterable[CollectorCandidate] = (), +) -> ModeMatrix: """Return the versioned mode definitions and complete collector inclusion matrix.""" aliases_by_mode = { name: sorted( @@ -133,41 +159,61 @@ def mode_matrix(candidates: Iterable[CollectorCandidate] = ()) -> dict[str, obje ) for name in EXECUTION_MODES } + ordered_candidates = sorted( candidates, key=lambda candidate: ( - candidate.metadata.id if candidate.metadata is not None else candidate.selection_id, + candidate.metadata.id + if candidate.metadata is not None + else candidate.selection_id, str(candidate.path), ), ) - collector_rows = [] - memberships: dict[str, list[str]] = {name: [] for name in EXECUTION_MODES} + + collector_rows: list[ModeMatrixCollector] = [] + memberships: dict[str, list[str]] = { + name: [] + for name in EXECUTION_MODES + } + for candidate in ordered_candidates: - collector_id = candidate.metadata.id if candidate.metadata is not None else candidate.selection_id + collector_id = ( + candidate.metadata.id + if candidate.metadata is not None + else candidate.selection_id + ) assigned_modes = _candidate_modes(candidate) + for mode_name in assigned_modes: memberships[mode_name].append(collector_id) + errors = [*candidate.static_errors] if candidate.runtime_error: errors.append(candidate.runtime_error) - collector_rows.append({ - "id": collector_id, - "kind": candidate.kind.value, - "valid": candidate.valid, - "modes": list(assigned_modes), - "manual_only": candidate.valid and not assigned_modes, - "execution_type": candidate.execution_type, - "validation_errors": errors, - }) - modes = [ + + collector_rows.append( + { + "id": collector_id, + "kind": candidate.kind.value, + "valid": candidate.valid, + "modes": list(assigned_modes), + "manual_only": candidate.valid and not assigned_modes, + "execution_type": candidate.execution_type, + "validation_errors": errors, + } + ) + + modes: list[ModeMatrixMode] = [ { - **asdict(mode), + "name": mode.name, + "description": mode.description, "strategy": mode.strategy.value, "legacy_aliases": aliases_by_mode[mode.name], "collector_ids": memberships[mode.name], } for mode in EXECUTION_MODES.values() ] + return { "schema_version": 1, "modes": modes, @@ -186,18 +232,35 @@ def render_mode_matrix_markdown(matrix: Mapping[str, object]) -> str: "| Collector | Kind | Valid | Included modes | Selection |", "| --- | --- | --- | --- | --- |", ] - for item in matrix.get("collectors", []): + + collectors = matrix.get("collectors", []) + if not isinstance(collectors, list): + collectors = [] + + for item in collectors: + if not isinstance(item, Mapping): + continue + collector = dict(item) - modes = ", ".join(f"`{name}`" for name in collector["modes"]) or "none" - if not collector["valid"]: + + raw_modes = collector.get("modes", []) + modes_iterable = raw_modes if isinstance(raw_modes, (list, tuple)) else [] + modes = ", ".join(f"`{name}`" for name in modes_iterable) or "none" + + valid = collector.get("valid") is True + manual_only = collector.get("manual_only") is True + + if not valid: selection = "quarantined" - elif collector["manual_only"]: + elif manual_only: selection = "explicit include only" else: selection = "mode selected" + rows.append( - f"| `{collector['id']}` | `{collector['kind']}` | " - f"{'yes' if collector['valid'] else 'no'} | {modes} | {selection} |" + f"| `{collector.get('id', '')}` | `{collector.get('kind', '')}` | " + f"{'yes' if valid else 'no'} | {modes} | {selection} |" ) + rows.append("") return "\n".join(rows) diff --git a/logicytics/packaging.py b/logicytics/packaging.py index 0815c534..a91e310a 100644 --- a/logicytics/packaging.py +++ b/logicytics/packaging.py @@ -9,7 +9,7 @@ import zipfile from datetime import datetime, timezone from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, BinaryIO +from typing import TYPE_CHECKING, IO from logicytics.artifacts import sha256_file from logicytics.contracts import Artifact @@ -189,7 +189,7 @@ def _stream_archive_member(archive: zipfile.ZipFile, source: Path, archive_name: writer.write(block) -def _sha256_stream(stream: BinaryIO) -> str: +def _sha256_stream(stream: IO[bytes]) -> str: """Hash an open binary stream without loading the evidence into memory.""" digest = hashlib.sha256() for block in iter(lambda: stream.read(_STREAM_BLOCK_BYTES), b""): @@ -269,6 +269,7 @@ def _package_mod_artifacts( digest = _sha256_stream(stream) if member.file_size != artifact.size_bytes or digest != artifact.sha256: raise ValueError(f"MODS artifact verification failed: {artifact.relative_path}") + digest = sha256_file(temporary_package) temporary_hash.write_text(f"{digest} {package_path.name}\n", encoding="ascii") package_backup = package_path.with_suffix(".zip.backup") diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 827e415a..487b35d8 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -28,83 +28,156 @@ class ProcessAdapter: create_new_console = getattr(subprocess, "CREATE_NEW_CONSOLE", 0x00000010) @staticmethod - def _command(command: Sequence[str]) -> tuple[str, ...]: + def _command( + command: Sequence[str | os.PathLike[str]], + ) -> tuple[str, ...]: """Validate and normalize an explicit shell-free command sequence.""" - normalized = tuple(str(argument) for argument in command) + normalized = tuple( + argument if isinstance(argument, str) else argument.__fspath__() + for argument in command + ) + if not normalized: raise ValueError("command must contain at least one argument") + return normalized - def run(self, command: Sequence[str], **options: Any) -> subprocess.CompletedProcess[str]: + def run( + self, + command: Sequence[str | os.PathLike[str]], + **options: Any, + ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" normalized = self._command(command) capture_directory = options.pop("capture_directory", None) + if options.get("shell"): raise ValueError("collector process adapters never permit shell execution") + timeout = options.get("timeout") - if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0): + + if timeout is not None and ( + not isinstance(timeout, (int, float)) + or timeout <= 0 + ): raise ValueError("command timeout must be positive") + capture_output = options.pop("capture_output", False) + if not capture_output: return subprocess.run(normalized, **options) + if "stdout" in options or "stderr" in options: - raise ValueError("capture_output cannot be combined with explicit streams") - wants_text = bool(options.pop("text", False) or options.get("encoding") is not None) + raise ValueError( + "capture_output cannot be combined with explicit streams" + ) + + wants_text = bool( + options.pop("text", False) + or options.get("encoding") is not None + ) encoding = options.pop("encoding", None) or "utf-8" errors = options.pop("errors", None) or "replace" + if capture_directory is not None: capture_directory = Path(capture_directory) + if not capture_directory.is_dir(): - raise ValueError("capture_directory must be an existing directory") + raise ValueError( + "capture_directory must be an existing directory" + ) + with ( tempfile.TemporaryFile(dir=capture_directory) as stdout, tempfile.TemporaryFile(dir=capture_directory) as stderr, ): - completed = subprocess.run(normalized, stdout=stdout, stderr=stderr, **options) + completed = subprocess.run( + normalized, + stdout=stdout, + stderr=stderr, + **options, + ) + stdout_size = stdout.tell() stderr_size = stderr.tell() - if stdout_size > self.maximum_capture_bytes or stderr_size > self.maximum_capture_bytes: + + if ( + stdout_size > self.maximum_capture_bytes + or stderr_size > self.maximum_capture_bytes + ): raise ValueError( - f"command output exceeds the {self.maximum_capture_bytes}-byte capture limit" + f"command output exceeds the " + f"{self.maximum_capture_bytes}-byte capture limit" ) + stdout.seek(0) stderr.seek(0) + stdout_bytes = stdout.read() stderr_bytes = stderr.read() - captured_stdout = stdout_bytes.decode(encoding, errors) if wants_text else stdout_bytes - captured_stderr = stderr_bytes.decode(encoding, errors) if wants_text else stderr_bytes - return subprocess.CompletedProcess( - normalized, completed.returncode, captured_stdout, captured_stderr + + if wants_text: + return subprocess.CompletedProcess[str]( + normalized, + completed.returncode, + stdout_bytes.decode(encoding, errors), + stderr_bytes.decode(encoding, errors), + ) + + return subprocess.CompletedProcess[bytes]( + normalized, + completed.returncode, + stdout_bytes, + stderr_bytes, ) - def popen(self, command: Sequence[str], **options: Any) -> subprocess.Popen[Any]: + def popen( + self, + command: Sequence[str | os.PathLike[str]], + **options: Any, + ) -> subprocess.Popen[Any]: """Start one explicit long-lived process without invoking a command shell.""" normalized = self._command(command) + if options.get("shell"): raise ValueError("process adapters never permit shell execution") + return subprocess.Popen(normalized, **options) @staticmethod def memory_bytes(process_id: int) -> int | None: """Return one process's resident memory through the host-specific boundary.""" - if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: + if ( + not isinstance(process_id, int) + or isinstance(process_id, bool) + or process_id <= 0 + ): raise ValueError("process_id must be a positive integer") + if os.name == "nt": return windows_api_adapter.process_working_set(process_id) + status_path = Path(f"/proc/{process_id}/status") + try: for line in status_path.read_text(encoding="ascii").splitlines(): if line.startswith("VmRSS:"): return int(line.split()[1]) * 1024 except (OSError, ValueError, IndexError): return None + return None @staticmethod def terminate_process_group(process_id: int) -> None: """Request termination of one non-Windows process group.""" - if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: + if ( + not isinstance(process_id, int) + or isinstance(process_id, bool) + or process_id <= 0 + ): raise ValueError("process_id must be a positive integer") + os.killpg(process_id, signal.SIGTERM) diff --git a/logicytics/runtime.py b/logicytics/runtime.py index 98b1c4c5..b7e3979c 100644 --- a/logicytics/runtime.py +++ b/logicytics/runtime.py @@ -17,9 +17,12 @@ import traceback import zipfile from dataclasses import asdict, dataclass +from multiprocessing.process import BaseProcess +from multiprocessing.queues import Queue from pathlib import Path from subprocess import TimeoutExpired from time import monotonic, sleep +from typing import Any, Mapping, TypedDict, cast, Callable, Protocol, TypeVar from uuid import uuid4 from logicytics.artifacts import WorkspaceArtifactWriter @@ -48,6 +51,8 @@ from logicytics.planner import RunPlan from logicytics.platform_adapters import process_adapter, windows_api_adapter +_T = TypeVar("_T") + @dataclass(slots=True) class RunOutcome: @@ -58,10 +63,65 @@ class RunOutcome: manifest_path: Path +class _ProcessContext(Protocol): + """Typed multiprocessing context subset used by the supervisor.""" + + def Process( + self, + group: None = None, + target: Callable[..., object] | None = None, + name: str | None = None, + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, + *, + daemon: bool | None = None, + ) -> BaseProcess: + ... + + def Queue( + self, + maxsize: int = 0, + ) -> Queue[_T]: + ... + + +class _WorkerPayload(TypedDict): + """Fully typed, pickle-safe payload passed to collector worker processes.""" + + run_id: str + collector_id: str + path: str + expected_class: str + execution_type: str + metadata: dict[str, object] + workspace: str + artifact_root: str + cancellation_file: str + settings: dict[str, Any] + run_output_budget_bytes: int + + +class _SerializedResult(TypedDict): + """Serialized CollectorResult shape transported through multiprocessing queues.""" + + status: str + summary: str + artifacts: list[dict[str, object]] + errors: list[str] + metrics: dict[str, int | float | str] + + +class _WorkerMessage(TypedDict): + """Terminal result message produced by a worker process.""" + + collector_id: str + result: _SerializedResult + + @dataclass(slots=True) class _ActiveWorker: candidate_id: str - process: multiprocessing.Process + process: BaseProcess started_at: float timeout_seconds: int maximum_memory_bytes: int @@ -75,6 +135,14 @@ class _ActiveWorker: exited_at: float | None = None +def _require_metadata(candidate: CollectorCandidate) -> CollectorMetadata: + """Return preflight metadata or fail immediately on an invalid supervisor input.""" + metadata = candidate.metadata + if metadata is None: + raise RuntimeError(f"preflighted collector has no metadata: {candidate.path}") + return metadata + + class _WorkerMutationGuard: """Constrain audited collector mutations to its private workspace and evidence store.""" @@ -159,8 +227,19 @@ def _subprocess_tokens(arguments: tuple[object, ...]) -> tuple[str, ...]: """Normalize cross-platform subprocess audit arguments without executing a shell.""" executable = arguments[0] if arguments else None command = arguments[1] if len(arguments) > 1 else None + if isinstance(command, (list, tuple)): - tokens = tuple(str(item) for item in command) + tokens = tuple( + os.fsdecode(item) + for item in command + if isinstance(item, (str, bytes, os.PathLike)) + ) + elif isinstance(command, bytes): + decoded = os.fsdecode(command) + try: + tokens = tuple(shlex.split(decoded, posix=os.name != "nt")) + except ValueError: + tokens = tuple(decoded.split()) elif isinstance(command, str): try: tokens = tuple(shlex.split(command, posix=os.name != "nt")) @@ -168,9 +247,12 @@ def _subprocess_tokens(arguments: tuple[object, ...]) -> tuple[str, ...]: tokens = tuple(command.split()) else: tokens = () + if tokens: return tokens - return (str(executable),) if isinstance(executable, (str, bytes, os.PathLike)) else () + if isinstance(executable, (str, bytes, os.PathLike)): + return (os.fsdecode(executable),) + return () @staticmethod def _command_stem(value: str) -> str: @@ -236,7 +318,12 @@ def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: }: if Capability.NETWORK not in self.capabilities: raise PermissionError("collector requires declared network capability") - if event == "socket.__new__" and len(arguments) > 2 and int(arguments[2]) == int(socket.SOCK_RAW): + socket_type = arguments[2] if len(arguments) > 2 else None + if ( + event == "socket.__new__" + and isinstance(socket_type, int) + and socket_type == int(socket.SOCK_RAW) + ): if Capability.PACKET_CAPTURE not in self.capabilities: raise PermissionError("collector requires declared packet_capture capability") if event.startswith("winreg.") and Capability.REGISTRY_READ not in self.capabilities: @@ -273,17 +360,19 @@ def _load_collector(path: Path, expected_class: str): return getattr(module, expected_class)() -def _artifact_from_dict(data: dict[str, object]) -> Artifact: - return Artifact.from_dict(data) +def _artifact_from_dict(data: Mapping[str, object]) -> Artifact: + """Deserialize one artifact from an already validated mapping.""" + return Artifact.from_dict(dict(data)) -def _result_from_dict(data: dict[str, object]) -> CollectorResult: +def _result_from_dict(data: _SerializedResult) -> CollectorResult: + """Deserialize the typed result payload emitted by a worker.""" return CollectorResult( - status=CollectorStatus(str(data["status"])), - summary=str(data["summary"]), - artifacts=tuple(_artifact_from_dict(item) for item in data.get("artifacts", [])), - errors=tuple(str(error) for error in data.get("errors", [])), - metrics=dict(data.get("metrics", {})), + status=CollectorStatus(data["status"]), + summary=data["summary"], + artifacts=tuple(_artifact_from_dict(item) for item in data["artifacts"]), + errors=tuple(data["errors"]), + metrics=dict(data["metrics"]), ) @@ -317,15 +406,15 @@ def _mod_command( raise ValueError(f"unsupported mod execution type: {execution_type}") -def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Queue) -> None: +def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) -> None: """Adapt a sidecar-declared legacy script to the isolated collector contract.""" - metadata = CollectorMetadata.from_dict(dict(payload["metadata"]), allow_custom_specialty=True) - workspace = Path(str(payload["workspace"])) - artifact_root = Path(str(payload["artifact_root"])) - cancellation_file = Path(str(payload["cancellation_file"])) + metadata = CollectorMetadata.from_dict(payload["metadata"], allow_custom_specialty=True) + workspace = Path(payload["workspace"]) + artifact_root = Path(payload["artifact_root"]) + cancellation_file = Path(payload["cancellation_file"]) source_directory = workspace / "source" source_directory.mkdir(parents=True, exist_ok=True) - source = Path(str(payload["path"])) + source = Path(payload["path"]) copied_script = source_directory / source.name shutil.copy2(source, copied_script) writer = WorkspaceArtifactWriter( @@ -334,14 +423,14 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu artifact_root, metadata.maximum_output_bytes, metadata.maximum_artifact_files, - source_category=metadata.specialty.value if hasattr(metadata.specialty, "value") else metadata.specialty, + source_category=str(metadata.specialty), maximum_artifact_bytes=metadata.maximum_artifact_bytes, - run_output_budget_bytes=int(payload["run_output_budget_bytes"]), + run_output_budget_bytes=payload["run_output_budget_bytes"], cancellation_file=cancellation_file, ) logger = get_event_logger( workspace / "events.jsonl", - run_id=str(payload["run_id"]), + run_id=payload["run_id"], collector_id=metadata.id, ) stdout_path = workspace / "script_stdout.txt" @@ -354,7 +443,7 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu else: command = _mod_command( copied_script, - str(payload["execution_type"]), + payload["execution_type"], workspace, metadata.id, metadata.capabilities, @@ -366,7 +455,7 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu "COMSPEC": os.environ.get("COMSPEC", ""), "TEMP": str(workspace / "tmp"), "TMP": str(workspace / "tmp"), - "LOGICYTICS_RUN_ID": str(payload["run_id"]), + "LOGICYTICS_RUN_ID": payload["run_id"], "LOGICYTICS_COLLECTOR_ID": metadata.id, "LOGICYTICS_WORKSPACE": str(workspace), } @@ -424,20 +513,20 @@ def _run_mod_worker(payload: dict[str, object], result_queue: multiprocessing.Qu result_queue.put({"collector_id": metadata.id, "result": _serialize_result(result)}) -def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queue) -> None: +def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) -> None: """Run a single collector in an isolated child process.""" if os.name != "nt": os.setsid() - workspace = Path(str(payload["workspace"])) + workspace = Path(payload["workspace"]) workspace.mkdir(parents=True, exist_ok=True) temporary_directory = workspace / "tmp" temporary_directory.mkdir(exist_ok=True) - if str(payload.get("execution_type", "collector")) != "collector": + if payload["execution_type"] != "collector": try: _run_mod_worker(payload, result_queue) except BaseException as error: result_queue.put({ - "collector_id": str(payload["collector_id"]), + "collector_id": payload["collector_id"], "result": _serialize_result(CollectorResult.failed( "legacy mod worker crashed", errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), @@ -449,9 +538,9 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu try: with stdout_path.open("w", encoding="utf-8") as stdout, stderr_path.open("w", encoding="utf-8") as stderr: with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): - collector = _load_collector(Path(str(payload["path"])), str(payload["expected_class"])) + collector = _load_collector(Path(payload["path"]), payload["expected_class"]) metadata = collector.metadata() - source_path = Path(str(payload["path"])).resolve() + source_path = Path(payload["path"]).resolve() shipped_core_root = (Path(__file__).resolve().parent.parent / "core").resolve() try: source_path.relative_to(shipped_core_root) @@ -462,43 +551,39 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu writer = WorkspaceArtifactWriter( metadata.id, workspace, - Path(str(payload["artifact_root"])), + Path(payload["artifact_root"]), metadata.maximum_output_bytes, metadata.maximum_artifact_files, - source_category=( - metadata.specialty.value - if hasattr(metadata.specialty, "value") - else metadata.specialty - ), + source_category=str(metadata.specialty), maximum_artifact_bytes=metadata.maximum_artifact_bytes, - run_output_budget_bytes=int(payload["run_output_budget_bytes"]), - cancellation_file=Path(str(payload["cancellation_file"])), + run_output_budget_bytes=payload["run_output_budget_bytes"], + cancellation_file=Path(payload["cancellation_file"]), allowed_relative_paths=(output_contract.workspace_patterns if output_contract else None), allowed_media_types=(output_contract.media_types if output_contract else None), ) context = CollectorContext( - run_id=str(payload["run_id"]), + run_id=payload["run_id"], collector_id=metadata.id, workspace=workspace, temporary_directory=temporary_directory, artifacts=writer, logger=get_event_logger( workspace / "events.jsonl", - run_id=str(payload["run_id"]), + run_id=payload["run_id"], collector_id=metadata.id, ), settings=dict(payload["settings"]), - cancellation_file=Path(str(payload["cancellation_file"])), + cancellation_file=Path(payload["cancellation_file"]), ) result: CollectorResult | None = None lifecycle_errors: list[str] = [] failure_summary = "collector worker crashed" mutation_guard = _WorkerMutationGuard( workspace, - Path(str(payload["artifact_root"])), + Path(payload["artifact_root"]), metadata.id, metadata.capabilities, - Path(str(payload["path"])), + Path(payload["path"]), ) mutation_guard.active = True try: @@ -581,7 +666,7 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu except BaseException as error: # child processes must always report a terminal result result_queue.put( { - "collector_id": str(payload["collector_id"]), + "collector_id": payload["collector_id"], "result": _serialize_result( CollectorResult( CollectorStatus.FAILED, @@ -593,13 +678,20 @@ def _worker_entry(payload: dict[str, object], result_queue: multiprocessing.Queu ) -def _serialize_result(result: CollectorResult) -> dict[str, object]: +def _serialize_result(result: CollectorResult) -> _SerializedResult: return { "status": result.status.value, "summary": result.summary, - "artifacts": [artifact.to_dict() for artifact in result.artifacts], + "artifacts": [ + dict(cast(Mapping[str, object], artifact.to_dict())) + for artifact in result.artifacts + ], "errors": list(result.errors), - "metrics": dict(result.metrics), + "metrics": { + key: value + for key, value in result.metrics.items() + if isinstance(value, (int, float, str)) and not isinstance(value, bool) + }, } @@ -614,19 +706,13 @@ def __init__(self, project_root: Path, configuration: AppConfig) -> None: def run(self, plan: RunPlan) -> RunOutcome: """Execute a preflighted plan and persist the manifest throughout the run.""" if plan.collectors and not plan.request.acknowledge_authorization: - categories = sorted( - candidate.metadata.specialty.value - if hasattr(candidate.metadata.specialty, "value") - else candidate.metadata.specialty - for candidate in plan.collectors - if candidate.metadata is not None - ) + selected_metadata = tuple(_require_metadata(candidate) for candidate in plan.collectors) + categories = sorted(str(metadata.specialty) for metadata in selected_metadata) sensitive_outputs = sorted( { category - for candidate in plan.collectors - if candidate.metadata is not None - for category in candidate.metadata.sensitive_data_categories + for metadata in selected_metadata + for category in metadata.sensitive_data_categories } ) raise PermissionError( @@ -655,7 +741,7 @@ def run(self, plan: RunPlan) -> RunOutcome: run_id, asdict(plan.request), self.configuration.to_manifest_dict(), - [(candidate.metadata.id, candidate.path) for candidate in plan.collectors if candidate.metadata], + [(_require_metadata(candidate).id, candidate.path) for candidate in plan.collectors], parent_run_id=plan.request.rerun_from, plan_fingerprint=plan.fingerprint, ) @@ -797,22 +883,27 @@ def _supervise( """Schedule bounded isolated workers and contain each terminal failure.""" pending = list(plan.collectors) candidates = { - candidate.metadata.id: candidate + _require_metadata(candidate).id: candidate for candidate in plan.collectors - if candidate.metadata is not None } active: dict[str, _ActiveWorker] = {} retry_not_before: dict[str, float] = {} self._active_workers = active - result_queue: multiprocessing.Queue = multiprocessing.get_context("spawn").Queue() + + spawn_context = cast( + _ProcessContext, + cast(object, multiprocessing.get_context("spawn")), + ) + + result_queue: Queue[_WorkerMessage] = spawn_context.Queue() worker_limit = min(plan.request.max_workers, self.configuration.runtime.maximum_workers) committed_output_bytes = 0 while pending or active: if cancellation_file.exists(): self._cancel_active(active, records, manifest, manifest_path, "run cancellation requested") for candidate in pending: - assert candidate.metadata is not None - records[candidate.metadata.id].apply_result( + metadata = _require_metadata(candidate) + records[metadata.id].apply_result( CollectorResult(CollectorStatus.CANCELLED, "not started because run was cancelled") ) return @@ -820,12 +911,12 @@ def _supervise( if any(not worker.parallel_safe for worker in active.values()): break candidate = pending[0] - assert candidate.metadata is not None - if monotonic() < retry_not_before.get(candidate.metadata.id, 0): + metadata = _require_metadata(candidate) + if monotonic() < retry_not_before.get(metadata.id, 0): break dependency_states = { dependency: records[dependency].status - for dependency in candidate.metadata.dependencies + for dependency in metadata.dependencies } failed_dependencies = { dependency: status @@ -838,7 +929,7 @@ def _supervise( f"{dependency}={status}" for dependency, status in sorted(failed_dependencies.items()) ) - records[candidate.metadata.id].apply_result( + records[metadata.id].apply_result( CollectorResult( CollectorStatus.SKIPPED, "collector dependency was not satisfied", @@ -848,7 +939,7 @@ def _supervise( run_logger.event( "warning", "collector_dependency_unsatisfied", - collector_id=candidate.metadata.id, + collector_id=metadata.id, ) write_manifest(manifest_path, manifest) continue @@ -856,11 +947,11 @@ def _supervise( if any(status != "succeeded" for status in dependency_states.values()): break - if active and not candidate.metadata.parallel_safe: + if active and not metadata.parallel_safe: break if active: - candidate_class = candidate.metadata.resource_class + candidate_class = metadata.resource_class has_interactive_worker = any( worker.resource_class is ResourceClass.INTERACTIVE @@ -894,7 +985,7 @@ def _supervise( if active: break pending.pop(0) - records[candidate.metadata.id].apply_result( + records[metadata.id].apply_result( CollectorResult( CollectorStatus.SKIPPED, "run output limit reached before collector could start", @@ -904,52 +995,52 @@ def _supervise( run_logger.event( "warning", "collector_run_output_limit_reached", - collector_id=candidate.metadata.id, + collector_id=metadata.id, ) write_manifest(manifest_path, manifest) continue - output_budget = min(candidate.metadata.maximum_output_bytes, remaining_output_bytes) + output_budget = min(metadata.maximum_output_bytes, remaining_output_bytes) pending.pop(0) - retry_not_before.pop(candidate.metadata.id, None) - workspace = workspace_root / candidate.metadata.id.replace(".", "_") - payload: dict[str, object] = { + retry_not_before.pop(metadata.id, None) + workspace = workspace_root / metadata.id.replace(".", "_") + payload: _WorkerPayload = { "run_id": run_id, - "collector_id": candidate.metadata.id, + "collector_id": metadata.id, "path": str(candidate.path), "expected_class": candidate.expected_class, "execution_type": candidate.execution_type, - "metadata": candidate.metadata.to_dict(), + "metadata": dict(cast(Mapping[str, object], metadata.to_dict())), "workspace": str(workspace), "artifact_root": str(artifact_root), "cancellation_file": str(cancellation_file), - "settings": dict(self.configuration.settings_for(candidate.metadata.id)), + "settings": dict(self.configuration.settings_for(metadata.id)), "run_output_budget_bytes": output_budget, } - process = multiprocessing.get_context("spawn").Process( + process = spawn_context.Process( target=_worker_entry, args=(payload, result_queue), - name=f"Logicytics-{candidate.metadata.id}", + name=f"Logicytics-{metadata.id}", ) - records[candidate.metadata.id].status = "running" - records[candidate.metadata.id].started_at = utc_now() - records[candidate.metadata.id].heartbeat_at = utc_now() - records[candidate.metadata.id].attempt_count += 1 + records[metadata.id].status = "running" + records[metadata.id].started_at = utc_now() + records[metadata.id].heartbeat_at = utc_now() + records[metadata.id].attempt_count += 1 process.start() - records[candidate.metadata.id].worker_pid = process.pid - active[candidate.metadata.id] = _ActiveWorker( - candidate.metadata.id, + records[metadata.id].worker_pid = process.pid + active[metadata.id] = _ActiveWorker( + metadata.id, process, monotonic(), - candidate.metadata.timeout_seconds, - candidate.metadata.maximum_memory_bytes, + metadata.timeout_seconds, + metadata.maximum_memory_bytes, workspace, - candidate.metadata.parallel_safe, - candidate.metadata.resource_class, + metadata.parallel_safe, + metadata.resource_class, output_budget, ) - run_logger.event("info", "collector_started", collector_id=candidate.metadata.id) + run_logger.event("info", "collector_started", collector_id=metadata.id) write_manifest(manifest_path, manifest) - if not candidate.metadata.parallel_safe: + if not metadata.parallel_safe: break try: @@ -957,7 +1048,7 @@ def _supervise( except queue.Empty: message = None if message is not None: - collector_id = str(message["collector_id"]) + collector_id = message["collector_id"] worker = active.pop(collector_id, None) if worker is not None: worker.process.join(timeout=1) @@ -1074,11 +1165,12 @@ def _supervise( sleep(0.01) @staticmethod - def _worker_memory_bytes(process: multiprocessing.Process) -> int | None: + def _worker_memory_bytes(process: BaseProcess) -> int | None: """Return one worker's resident working set using local OS facilities.""" - if process.pid is None: + pid = process.pid + if pid is None: return None - return process_adapter.memory_bytes(process.pid) + return process_adapter.memory_bytes(pid) @staticmethod def _schedule_retry( @@ -1091,10 +1183,10 @@ def _schedule_retry( run_logger: FileEventLogger, ) -> bool: """Retry only explicitly permitted failed attempts that produced no evidence.""" - assert candidate.metadata is not None + metadata = _require_metadata(candidate) if ( record.status != CollectorStatus.FAILED.value - or record.attempt_count > candidate.metadata.maximum_retries + or record.attempt_count > metadata.maximum_retries or artifact_bytes != 0 or cancellation_file.exists() ): @@ -1107,7 +1199,7 @@ def _schedule_retry( "finished_at": record.finished_at, "summary": record.summary, "errors": list(record.errors), - "failure": dict(record.failure) if record.failure is not None else None, + "failure": dict(record.failure) if isinstance(record.failure, Mapping) else None, "duration_seconds": record.duration_seconds, "worker_pid": record.worker_pid, "worker_exit_code": record.worker_exit_code, @@ -1125,7 +1217,7 @@ def _schedule_retry( record.worker_pid = None record.worker_exit_code = None record.termination_reason = None - retry_not_before[record.id] = monotonic() + candidate.metadata.retry_delay_seconds + retry_not_before[record.id] = monotonic() + metadata.retry_delay_seconds pending.insert(0, candidate) run_logger.event( "warning", @@ -1158,14 +1250,15 @@ def _windows_descendants(parent_pid: int) -> tuple[int, ...]: return windows_api_adapter.process_descendants(parent_pid) @staticmethod - def _terminate_process_tree(process: multiprocessing.Process) -> None: + def _terminate_process_tree(process: BaseProcess) -> None: """Terminate one worker-owned process tree without touching peer workers.""" - if process.pid is not None: + pid = process.pid + if pid is not None: try: if os.name == "nt": - descendants = RunSupervisor._windows_descendants(process.pid) + descendants = RunSupervisor._windows_descendants(pid) process_adapter.run( - ["taskkill", "/PID", str(process.pid), "/T", "/F"], + ["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, check=False, text=True, @@ -1174,7 +1267,7 @@ def _terminate_process_tree(process: multiprocessing.Process) -> None: for child_pid in descendants: windows_api_adapter.terminate_process(child_pid) else: - process_adapter.terminate_process_group(process.pid) + process_adapter.terminate_process_group(pid) except (OSError, TimeoutExpired): pass if process.is_alive(): @@ -1182,7 +1275,7 @@ def _terminate_process_tree(process: multiprocessing.Process) -> None: process.join(timeout=2) @staticmethod - def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: + def _refresh_worker_progress(record: CollectorRecord, worker: _ActiveWorker) -> bool: """Persist worker liveness and any newly written structured progress events.""" changed = False now = monotonic() @@ -1239,7 +1332,7 @@ def _refresh_worker_progress(record, worker: _ActiveWorker) -> bool: return changed @staticmethod - def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) -> None: + def _apply_worker_result(record: CollectorRecord, result: CollectorResult, worker: _ActiveWorker) -> None: """Attach result, elapsed time, and progress accounting to one record.""" record.apply_result(result, duration_seconds=round(monotonic() - worker.started_at, 3)) record.progress["bytes_written"] = max( @@ -1256,7 +1349,14 @@ def _apply_worker_result(record, result: CollectorResult, worker: _ActiveWorker) ) RunSupervisor._refresh_worker_progress(record, worker) - def _cancel_active(self, active, records, manifest, manifest_path, reason: str) -> None: + def _cancel_active( + self, + active: dict[str, _ActiveWorker], + records: dict[str, CollectorRecord], + manifest: RunManifest, + manifest_path: Path, + reason: str, + ) -> None: for collector_id, worker in tuple(active.items()): self._terminate_process_tree(worker.process) active.pop(collector_id) @@ -1269,7 +1369,13 @@ def _cancel_active(self, active, records, manifest, manifest_path, reason: str) self._cleanup_worker_temporary_directory(worker) write_manifest(manifest_path, manifest) - def _cancel_records(self, records, manifest, manifest_path, reason: str) -> None: + def _cancel_records( + self, + records: dict[str, CollectorRecord], + manifest: RunManifest, + manifest_path: Path, + reason: str, + ) -> None: self._cancel_active(self._active_workers, records, manifest, manifest_path, reason) for record in records.values(): if record.status in {"planned", "running"}: diff --git a/logicytics/validation_worker.py b/logicytics/validation_worker.py index 2644066e..9ebc92e3 100644 --- a/logicytics/validation_worker.py +++ b/logicytics/validation_worker.py @@ -19,18 +19,18 @@ CollectorMetadata, CoreCollector, PluginCollector, - ValidationResult, + ValidationResult, EventLogger, Artifact, EvidenceKind, ArtifactWriter, ) -class _ProbeLogger: +class _ProbeLogger(EventLogger): """Discard structured events emitted during the side-effect-free validation probe.""" def event(self, level: str, message: str, **fields: int | float | str) -> None: """Accept probe events without exposing them through the JSON-only worker output.""" -class _ProbeArtifactWriter: +class _ProbeArtifactWriter(ArtifactWriter): """Reject evidence registration during preflight validation.""" def register_file( @@ -38,8 +38,9 @@ def register_file( source: Path, *, media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, transformations: tuple[str, ...] = (), - ): + ) -> Artifact: """Prevent a validation method from registering collection artifacts.""" raise RuntimeError("validate() must not register artifacts") @@ -230,9 +231,16 @@ def main() -> int: expected_class = sys.argv[3] sys.path.insert(0, str(path.parent)) module = _load_module(path) - collector_type = getattr(module, expected_class, None) - if not inspect.isclass(collector_type): + collector_object = getattr(module, expected_class, None) + + if not inspect.isclass(collector_object): raise ValueError(f"missing collector class: {expected_class}") + + collector_type = cast(type[Collector], collector_object) + + if not issubclass(collector_type, Collector): + raise ValueError(f"{expected_class} is not a Collector") + metadata = _validate_contract(collector_type, kind) print(json.dumps({"metadata": metadata.to_dict()}, sort_keys=True)) return 0 diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index cc71b3b8..7248b094 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -6,7 +6,9 @@ import subprocess import tempfile import unittest +import zipfile from pathlib import Path +from typing import Any from unittest.mock import patch from core.browser import browser_data_backup @@ -17,214 +19,613 @@ from core.process import memory_map from core.ssh import ssh_backup from core.system import windows_system_data_backup -from logicytics.contracts import CollectorStatus +from logicytics.contracts import ( + Artifact, + ArtifactWriter, + CollectorContext, + CollectorStatus, + EvidenceKind, + EventLogger, +) + + +class _RejectingArtifactWriter(ArtifactWriter): + """Reject artifact publication during cancellation tests.""" + + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), + ) -> Artifact: + raise AssertionError( + f"cancelled collector unexpectedly registered {source}" + ) -class _RejectingArtifacts: - def register_file(self, *_args, **_kwargs): - raise AssertionError("cancelled collection must not register unpublished evidence") +class _NullEventLogger(EventLogger): + """Discard structured progress events emitted during tests.""" + def event( + self, + level: str, + message: str, + **fields: int | float | str, + ) -> None: + return None -class _Context: - def __init__(self, workspace: Path, settings: dict[str, object] | None = None) -> None: - self.workspace = workspace - self.temporary_directory = workspace / "tmp" - self.settings = settings or {} - self.artifacts = _RejectingArtifacts() - self.cancelled = False - @property - def is_cancelled(self) -> bool: - return self.cancelled +def _context( + workspace: Path, + settings: dict[str, object] | None = None, + *, + collector_id: str = "core.test.cancellation", +) -> tuple[CollectorContext, Path]: + """Create a real CollectorContext and its cancellation sentinel.""" - @staticmethod - def report_progress(_event: str, **_metrics: int | float | str) -> None: - return None + workspace.mkdir(parents=True, exist_ok=True) + + temporary_directory = workspace / "tmp" + temporary_directory.mkdir(parents=True, exist_ok=True) + + cancellation_file = workspace / ".cancel" + + context = CollectorContext( + run_id="test-cancellation", + collector_id=collector_id, + workspace=workspace, + temporary_directory=temporary_directory, + artifacts=_RejectingArtifactWriter(), + logger=_NullEventLogger(), + settings=settings or {}, + cancellation_file=cancellation_file, + ) + + return context, cancellation_file class CollectorCancellationTests(unittest.TestCase): + """Verify cooperative cancellation across long-running collectors.""" + def test_sensitive_copy_cancellation_removes_unpublished_bytes(self) -> None: - """Cancellation after a concurrent sensitive copy leaves no unregistered evidence.""" + """Cancellation during a sensitive-file copy removes unpublished data.""" + with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) + source_root = root / "source" source_root.mkdir() - (source_root / "password.txt").write_text("sensitive", encoding="utf-8") - context = _Context(root / "workspace", {"root": str(source_root), "max_directories": 10, - "max_matches": 10}) - context.workspace.mkdir() - def cancel_after_copy(source: Path, destination: Path) -> Path: + (source_root / "password.txt").write_text( + "sensitive", + encoding="utf-8", + ) + + context, cancellation_file = _context( + root / "workspace", + { + "root": str(source_root), + "max_directories": 10, + "max_matches": 10, + }, + collector_id="core.filesystem.sensitive_file_inventory", + ) + + def cancel_after_copy( + source: Path, + destination: Path, + ) -> Path: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) - context.cancelled = True + cancellation_file.touch() return destination - with patch.object(sensitive_file_inventory, "_copy", side_effect=cancel_after_copy): - result = sensitive_file_inventory.SensitiveFileInventoryCollector().collect(context) - - self.assertEqual(CollectorStatus.CANCELLED, result.status) - self.assertEqual([], [path for path in context.workspace.rglob("*") if path.is_file()]) + with patch( + "core.filesystem.sensitive_file_inventory._copy", + side_effect=cancel_after_copy, + ): + result = ( + sensitive_file_inventory + .SensitiveFileInventoryCollector() + .collect(context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + result.status, + ) + + self.assertEqual( + [], + [ + path + for path in context.workspace.rglob("*") + if path.is_file() and path != cancellation_file + ], + ) + + def test_browser_and_media_copy_cancellation_remove_unpublished_bytes( + self, + ) -> None: + """Browser and media collectors remove incomplete private copies.""" - def test_browser_and_media_copy_cancellation_remove_unpublished_bytes(self) -> None: - """Source-data collectors clean copied bytes when cancellation arrives during a file copy.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - browser_context = _Context(root / "browser-workspace") - browser_context.workspace.mkdir() + local = root / "local" roaming = root / "roaming" - profile = local / "Google" / "Chrome" / "User Data" / "Default" + + profile = ( + local + / "Google" + / "Chrome" + / "User Data" + / "Default" + ) profile.mkdir(parents=True) + (profile / "History").write_bytes(b"history") - def cancel_browser_copy(_source: Path, destination: Path, copied_bytes: int): + browser_context, browser_cancellation = _context( + root / "browser-workspace", + collector_id="core.browser.browser_data_backup", + ) + + def cancel_browser_copy( + _source: Path, + destination: Path, + copied_bytes: int, + ) -> tuple[Path, int]: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(b"history") - browser_context.cancelled = True + + browser_cancellation.touch() + return destination, copied_bytes + 7 - with patch.dict(os.environ, {"LOCALAPPDATA": str(local), "APPDATA": str(roaming)}), patch.object( - browser_data_backup, "_copy_file", side_effect=cancel_browser_copy): - browser_result = browser_data_backup.BrowserDataBackupCollector().collect(browser_context) - self.assertEqual(CollectorStatus.CANCELLED, browser_result.status) - self.assertEqual([], [path for path in browser_context.workspace.rglob("*") if path.is_file()]) + with ( + patch.dict( + os.environ, + { + "LOCALAPPDATA": str(local), + "APPDATA": str(roaming), + }, + ), + patch( + "core.browser.browser_data_backup._copy_file", + side_effect=cancel_browser_copy, + ), + ): + browser_result = ( + browser_data_backup + .BrowserDataBackupCollector() + .collect(browser_context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + browser_result.status, + ) + + self.assertEqual( + [], + [ + path + for path in browser_context.workspace.rglob("*") + if path.is_file() and path != browser_cancellation + ], + ) home = root / "home" pictures = home / "Pictures" pictures.mkdir(parents=True) + (pictures / "photo.jpg").write_bytes(b"photo") - media_context = _Context(root / "media-workspace") - media_context.workspace.mkdir() - def cancel_media_copy(source: Path, destination: Path) -> None: + media_context, media_cancellation = _context( + root / "media-workspace", + collector_id="core.media.media_backup", + ) + + def cancel_media_copy( + source: Path, + destination: Path, + ) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) - media_context.cancelled = True - with patch.object(media_backup.filesystem_adapter, "home", return_value=home), patch.object( - media_backup.filesystem_adapter, "copy_file", side_effect=cancel_media_copy): - media_result = media_backup.MediaBackupCollector().collect(media_context) - self.assertEqual(CollectorStatus.CANCELLED, media_result.status) - self.assertEqual([], [path for path in media_context.workspace.rglob("*") if path.is_file()]) + media_cancellation.touch() + + with ( + patch.object( + media_backup.filesystem_adapter, + media_backup.filesystem_adapter.home.__name__, + return_value=home, + ), + patch.object( + media_backup.filesystem_adapter, + media_backup.filesystem_adapter.copy_file.__name__, + side_effect=cancel_media_copy, + ), + ): + media_result = ( + media_backup + .MediaBackupCollector() + .collect(media_context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + media_result.status, + ) + + self.assertEqual( + [], + [ + path + for path in media_context.workspace.rglob("*") + if path.is_file() and path != media_cancellation + ], + ) def test_event_queries_cannot_publish_after_cancellation(self) -> None: - """All bounded event-log queries recheck cancellation after PowerShell exits.""" + """Event-log collectors recheck cancellation after PowerShell exits.""" + collectors = ( - (application_events, application_events.ApplicationEventsCollector), - (security_events, security_events.SecurityEventsCollector), - (system_events, system_events.SystemEventsCollector), + ( + application_events.ApplicationEventsCollector, + "core.event_log.application_events.subprocess.run", + "core.event_log.application_events", + ), + ( + security_events.SecurityEventsCollector, + "core.event_log.security_events.subprocess.run", + "core.event_log.security_events", + ), + ( + system_events.SystemEventsCollector, + "core.event_log.system_events.subprocess.run", + "core.event_log.system_events", + ), ) - with tempfile.TemporaryDirectory() as temporary: - for module, collector_type in collectors: - with self.subTest(collector=collector_type.__name__): - workspace = Path(temporary) / collector_type.__name__ - workspace.mkdir() - context = _Context(workspace) - def finish_after_cancellation(*_args, **_kwargs): - context.cancelled = True - return subprocess.CompletedProcess([], 0, "header\nrow\n", "") + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) - with patch.object(module.subprocess, "run", side_effect=finish_after_cancellation): + for collector_type, subprocess_target, collector_id in collectors: + with self.subTest( + collector=collector_type.__name__, + ): + context, cancellation_file = _context( + root / collector_type.__name__, + collector_id=collector_id, + ) + + def finish_after_cancellation( + *_args: Any, + **_kwargs: Any, + ) -> subprocess.CompletedProcess[str]: + cancellation_file.touch() + + return subprocess.CompletedProcess( + args=[], + returncode=0, + stdout="header\nrow\n", + stderr="", + ) + + with patch( + subprocess_target, + side_effect=finish_after_cancellation, + ): result = collector_type().collect(context) - self.assertEqual(CollectorStatus.CANCELLED, result.status) - self.assertEqual([], [path for path in workspace.rglob("*") if path.is_file()]) - def test_system_data_and_ssh_copy_cancellation_remove_partial_backups(self) -> None: - """System-data and archive loops remove private copies that were never registered.""" + self.assertEqual( + CollectorStatus.CANCELLED, + result.status, + ) + + self.assertEqual( + [], + [ + path + for path in context.workspace.rglob("*") + if path.is_file() + and path != cancellation_file + ], + ) + + def test_system_data_and_ssh_copy_cancellation_remove_partial_backups( + self, + ) -> None: + """System and SSH collectors delete private incomplete copies.""" + with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) + windows = root / "Windows" - event_log = windows / "System32" / "winevt" / "Logs" / "System.evtx" + + event_log = ( + windows + / "System32" + / "winevt" + / "Logs" + / "System.evtx" + ) + event_log.parent.mkdir(parents=True) event_log.write_bytes(b"event log") - system_context = _Context(root / "system-workspace") - system_context.workspace.mkdir() - def cancel_system_copy(source: Path, destination: Path) -> None: + system_context, system_cancellation = _context( + root / "system-workspace", + collector_id="core.system.windows_system_data_backup", + ) + + def cancel_system_copy( + source: Path, + destination: Path, + ) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) - system_context.cancelled = True - with patch.dict(os.environ, {"SystemRoot": str(windows), "ProgramData": str(root / "ProgramData")}), \ - patch.object(windows_system_data_backup.filesystem_adapter, "copy_file", - side_effect=cancel_system_copy): - system_result = windows_system_data_backup.WindowsSystemDataBackupCollector().collect(system_context) - self.assertEqual(CollectorStatus.CANCELLED, system_result.status) - self.assertEqual([], [path for path in system_context.workspace.rglob("*") if path.is_file()]) + system_cancellation.touch() + + with ( + patch.dict( + os.environ, + { + "SystemRoot": str(windows), + "ProgramData": str(root / "ProgramData"), + }, + ), + patch( + ( + "core.system.windows_system_data_backup." + "filesystem_adapter.copy_file" + ), + side_effect=cancel_system_copy, + ), + ): + system_result = ( + windows_system_data_backup + .WindowsSystemDataBackupCollector() + .collect(system_context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + system_result.status, + ) + + self.assertEqual( + [], + [ + path + for path in system_context.workspace.rglob("*") + if path.is_file() + and path != system_cancellation + ], + ) home = root / "home" + private_key = home / ".ssh" / "id_test" private_key.parent.mkdir(parents=True) private_key.write_bytes(b"private key") - ssh_context = _Context(root / "ssh-workspace") - ssh_context.workspace.mkdir() - original_write = ssh_backup.zipfile.ZipFile.write - - def cancel_archive_write(archive, *args: object, **kwargs: object) -> None: - original_write(archive, *args, **kwargs) - ssh_context.cancelled = True - - with patch.object(ssh_backup.filesystem_adapter, "home", return_value=home), patch.object( - ssh_backup.zipfile.ZipFile, "write", new=cancel_archive_write): - ssh_result = ssh_backup.SshBackupCollector().collect(ssh_context) - self.assertEqual(CollectorStatus.CANCELLED, ssh_result.status) - self.assertFalse((ssh_context.workspace / "ssh_backup.zip").exists()) - - def test_packet_and_memory_loops_stop_before_serialization(self) -> None: - """Capture and memory enumeration honor cancellation before materializing reports.""" + + ssh_context, ssh_cancellation = _context( + root / "ssh-workspace", + collector_id="core.ssh.ssh_backup", + ) + + original_write = zipfile.ZipFile.write + + def cancel_archive_write( + archive: zipfile.ZipFile, + filename: str | os.PathLike[str], + arcname: str | os.PathLike[str] | None = None, + compress_type: int | None = None, + compresslevel: int | None = None, + ) -> None: + bound_write = original_write.__get__(archive, type(archive)) + + bound_write( + filename, + arcname=arcname, + compress_type=compress_type, + compresslevel=compresslevel, + ) + + ssh_cancellation.touch() + + with ( + patch.object( + ssh_backup.filesystem_adapter, + ssh_backup.filesystem_adapter.home.__name__, + return_value=home, + ), + patch.object( + ssh_backup.zipfile.ZipFile, + ssh_backup.zipfile.ZipFile.write.__name__, + new=cancel_archive_write, + ), + ): + ssh_result = ( + ssh_backup + .SshBackupCollector() + .collect(ssh_context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + ssh_result.status, + ) + + self.assertFalse( + ( + ssh_context.workspace + / "ssh_backup.zip" + ).exists() + ) + + def test_packet_and_memory_loops_stop_before_serialization( + self, + ) -> None: + """Packet and memory loops stop before serializing evidence.""" + with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - packet_context = _Context(root / "packet", {"packet_count": 2, "timeout_seconds": 2, - "retry_window_seconds": 0, "interface": "127.0.0.1"}) - packet_context.workspace.mkdir() + + packet_context, packet_cancellation = _context( + root / "packet", + { + "packet_count": 2, + "timeout_seconds": 2, + "retry_window_seconds": 0, + "interface": "127.0.0.1", + }, + collector_id="core.packet.packet_capture", + ) class FakeSocket: + """Minimal raw-socket replacement.""" + @staticmethod - def bind(_address) -> None: + def bind(_address: object) -> None: return None @staticmethod - def setsockopt(*_args) -> None: + def setsockopt( + *_args: object, + ) -> None: return None @staticmethod - def ioctl(*_args) -> None: + def ioctl( + *_args: object, + ) -> None: return None + @staticmethod + def recvfrom( + _buffer_size: int, + ) -> tuple[bytes, tuple[str, int]]: + return b"", ("127.0.0.1", 0) + @staticmethod def close() -> None: return None - def cancel_wait(*_args): - packet_context.cancelled = True - return [], [], [] + def cancel_wait( + *_args: object, + **_kwargs: object, + ) -> tuple[ + list[object], + list[object], + list[object], + ]: + packet_cancellation.touch() - with patch.object(packet_capture.socket, "socket", return_value=FakeSocket()), patch.object( - packet_capture.select, "select", side_effect=cancel_wait): - packet_result = packet_capture.PacketCaptureCollector().collect(packet_context) - self.assertEqual(CollectorStatus.CANCELLED, packet_result.status) - self.assertFalse((packet_context.workspace / "packet_capture.csv").exists()) + return [], [], [] - memory_context = _Context(root / "memory", {"max_regions": 10, "output_limit_bytes": 4096, - "disk_safety_margin_bytes": 0, - "dump_directory": "maps"}) - memory_context.workspace.mkdir() + with ( + patch.object( + packet_capture.socket, + packet_capture.socket.socket.__name__, + return_value=FakeSocket(), + ), + patch.object( + packet_capture.select, + packet_capture.select.select.__name__, + side_effect=cancel_wait, + ), + ): + packet_result = ( + packet_capture + .PacketCaptureCollector() + .collect(packet_context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + packet_result.status, + ) + + self.assertFalse( + ( + packet_context.workspace + / "packet_capture.csv" + ).exists() + ) + + memory_context, memory_cancellation = _context( + root / "memory", + { + "max_regions": 10, + "output_limit_bytes": 4096, + "disk_safety_margin_bytes": 0, + "dump_directory": "maps", + }, + collector_id="core.process.memory_map", + ) class FakeKernel: + """Minimal kernel32 replacement.""" + @staticmethod - def GetCurrentProcess(): - memory_context.cancelled = True + def GetCurrentProcess() -> int: + memory_cancellation.touch() return 1 class FakePsapi: + """Minimal psapi replacement.""" + @staticmethod - def GetProcessMemoryInfo(*_args): + def GetProcessMemoryInfo( + *_args: object, + ) -> int: return 1 - with patch.object(memory_map.ctypes, "WinDLL", side_effect=(FakeKernel(), FakePsapi())): - memory_result = memory_map.MemoryMapCollector().collect(memory_context) - self.assertEqual(CollectorStatus.CANCELLED, memory_result.status) - self.assertEqual([], [path for path in memory_context.workspace.rglob("*") if path.is_file()]) + dlls = iter( + ( + FakeKernel(), + FakePsapi(), + ) + ) + + def fake_windll( + _name: str, + *_args: object, + **_kwargs: object, + ) -> object: + return next(dlls) + + with patch( + "core.process.memory_map.ctypes.WinDLL", + side_effect=fake_windll, + ): + memory_result = ( + memory_map + .MemoryMapCollector() + .collect(memory_context) + ) + + self.assertEqual( + CollectorStatus.CANCELLED, + memory_result.status, + ) + + self.assertEqual( + [], + [ + path + for path in memory_context.workspace.rglob("*") + if path.is_file() + and path != memory_cancellation + ], + ) if __name__ == "__main__": diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 58cc80c2..2c5a24d4 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -7,6 +7,7 @@ import tempfile import unittest from pathlib import Path +from typing import Sequence, BinaryIO from unittest.mock import Mock, patch from core.packet import packet_capture @@ -21,23 +22,42 @@ class ProcessAdapterTests(unittest.TestCase): def test_process_adapter_normalizes_and_delegates_shell_free_commands(self) -> None: - def execute(command, *, stdout, stderr, **_options): + def execute( + command: Sequence[str], + *, + stdout: BinaryIO, + stderr: BinaryIO, + **_options: object, + ) -> subprocess.CompletedProcess[str]: stdout.write(b"output") stderr.write(b"warning") return subprocess.CompletedProcess(command, 7) - with patch("logicytics.platform_adapters.subprocess.run", side_effect=execute) as invoke: - result = ProcessAdapter().run(["tool", Path("argument")], capture_output=True, - check=False, text=True, timeout=5) + with patch( + "logicytics.platform_adapters.subprocess.run", + side_effect=execute, + ) as invoke: + result = ProcessAdapter().run( + ["tool", Path("argument")], + capture_output=True, + check=False, + text=True, + timeout=5, + ) + self.assertEqual(("tool", "argument"), result.args) self.assertEqual(7, result.returncode) self.assertEqual("output", result.stdout) self.assertEqual("warning", result.stderr) self.assertEqual(("tool", "argument"), invoke.call_args.args[0]) - self.assertEqual({"check": False, "timeout": 5}, { - key: value for key, value in invoke.call_args.kwargs.items() - if key not in {"stdout", "stderr"} - }) + self.assertEqual( + {"check": False, "timeout": 5}, + { + key: value + for key, value in invoke.call_args.kwargs.items() + if key not in {"stdout", "stderr"} + }, + ) def test_process_adapter_rejects_captured_streams_over_the_hard_limit(self) -> None: adapter = ProcessAdapter() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index c32c68d0..1bfd12e4 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -95,6 +95,12 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N self.assertIn("Status: failed", summary) self.assertIn("Reasons:", summary) self.assertIn("RuntimeError", summary) + self.assertIsNotNone(record.started_at) + self.assertIsNotNone(record.finished_at) + + assert record.started_at is not None + assert record.finished_at is not None + self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index f8ac04a5..4a65be92 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -9,42 +9,78 @@ import unittest from contextlib import ExitStack from pathlib import Path +from typing import Any, NoReturn, cast, Callable from unittest.mock import patch from logicytics import ResourceClass from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.contracts import ( - ArtifactWriter, CollectorContext, CollectorStatus, EventLogger, EvidenceKind, + ArtifactWriter, + CollectorContext, + CollectorStatus, + EventLogger, + EvidenceKind, ) from logicytics.discovery import preflight from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.output_contracts import core_output_contract from logicytics.platform_adapters import ( - filesystem_adapter, network_adapter, process_adapter, registry_adapter, windows_api_adapter, + filesystem_adapter, + network_adapter, + process_adapter, + registry_adapter, + windows_api_adapter, ) class _RejectingWriter(ArtifactWriter): """Prove cancellation paths never attempt to publish evidence.""" - def register_file(self, source: Path, *, media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = ()): - raise AssertionError("a cancelled collector must not register an artifact") + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), + ) -> NoReturn: + raise AssertionError( + "a cancelled collector must not register an artifact" + ) class _NoopLogger(EventLogger): - def event(self, level: str, message: str, **fields: int | float | str) -> None: + """Discard test events.""" + + def event( + self, + level: str, + message: str, + **fields: int | float | str, + ) -> None: return None -def _load_collector(path: Path, class_name: str): +def _load_collector(path: Path, class_name: str) -> Any: + """Load and instantiate one collector class from a source module.""" module_name = f"contract_{path.parent.name}_{path.stem}" spec = importlib.util.spec_from_file_location(module_name, path) - assert spec is not None and spec.loader is not None + + if spec is None or spec.loader is None: + raise ImportError(f"unable to load collector module from {path}") + module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - return getattr(module, class_name)() + + collector_object: object = getattr(module, class_name, None) + + if not isinstance(collector_object, type): + raise TypeError( + f"{class_name} in {path} is missing or is not a class" + ) + + collector_factory = cast(Callable[[], Any], collector_object) + return collector_factory() class ShippedCollectorTests(unittest.TestCase): @@ -54,7 +90,18 @@ def test_shipped_collectors_pass_preflight(self) -> None: """The repository must never contain an invalid shipped collector.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + self.assertEqual((), report.invalid) + + discovered_ids: set[str] = set() + + for candidate in report.valid: + metadata = candidate.metadata + if metadata is None: + continue + + discovered_ids.add(metadata.id) + self.assertEqual( { "core.bluetooth.paired_devices", @@ -124,18 +171,28 @@ def test_shipped_collectors_pass_preflight(self) -> None: "core.encryption.bitlocker_status", "core.encryption.bitlocker_volumes", }, - {candidate.metadata.id for candidate in report.valid if candidate.metadata}, + discovered_ids, ) - def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling(self) -> None: + def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling( + self, + ) -> None: """The independent event channels may overlap while retaining separate identities.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) - event_collectors = { - candidate.metadata.id: candidate.metadata - for candidate in report.valid - if candidate.metadata and candidate.metadata.id.startswith("core.event_log.") - } + + event_collectors = {} + + for candidate in report.valid: + metadata = candidate.metadata + if metadata is None: + continue + + if not metadata.id.startswith("core.event_log."): + continue + + event_collectors[metadata.id] = metadata + self.assertEqual( { "core.event_log.application_events", @@ -144,104 +201,242 @@ def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling(self) }, set(event_collectors), ) + for metadata in event_collectors.values(): self.assertTrue(metadata.parallel_safe) - self.assertIs(ResourceClass.GENERAL, metadata.resource_class) - self.assertEqual(("text/csv",), metadata.output_media_types) - - def test_mode_matrix_represents_every_discovered_collector_exactly_once(self) -> None: + self.assertIs( + ResourceClass.GENERAL, + metadata.resource_class, + ) + self.assertEqual( + ("text/csv",), + metadata.output_media_types, + ) + + def test_mode_matrix_represents_every_discovered_collector_exactly_once( + self, + ) -> None: """Mode documentation covers selected, manual-only, and quarantined collectors.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) matrix = mode_matrix(report.candidates) + collector_rows = matrix["collectors"] - expected_ids = { - candidate.metadata.id if candidate.metadata is not None else candidate.selection_id - for candidate in report.candidates + + expected_ids: set[str] = set() + + for candidate in report.candidates: + metadata = candidate.metadata + + if metadata is not None: + expected_ids.add(metadata.id) + else: + expected_ids.add(candidate.selection_id) + + represented_ids = [ + row["id"] + for row in collector_rows + ] + + self.assertEqual( + expected_ids, + set(represented_ids), + ) + self.assertEqual( + len(represented_ids), + len(set(represented_ids)), + ) + + modes = { + row["name"]: row + for row in matrix["modes"] } - represented_ids = [row["id"] for row in collector_rows] - self.assertEqual(expected_ids, set(represented_ids)) - self.assertEqual(len(represented_ids), len(set(represented_ids))) - modes = {row["name"]: row for row in matrix["modes"]} - self.assertEqual(set(EXECUTION_MODES), set(modes)) + self.assertEqual( + set(EXECUTION_MODES), + set(modes), + ) + for collector in collector_rows: with self.subTest(collector=collector["id"]): self.assertEqual( collector["valid"] and not collector["modes"], collector["manual_only"], ) + for mode_name in collector["modes"]: - self.assertIn(collector["id"], modes[mode_name]["collector_ids"]) + self.assertIn( + collector["id"], + modes[mode_name]["collector_ids"], + ) + for mode_name, mode in modes.items(): expected = { - row["id"] for row in collector_rows if mode_name in row["modes"] + row["id"] + for row in collector_rows + if mode_name in row["modes"] } - self.assertEqual(expected, set(mode["collector_ids"])) - def test_every_core_collector_uses_context_artifacts_without_mutable_globals(self) -> None: + self.assertEqual( + expected, + set(mode["collector_ids"]), + ) + + def test_every_core_collector_uses_context_artifacts_without_mutable_globals( + self, + ) -> None: """Migration is complete only when all core modules use owned workspaces and catalogs.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + core_candidates = [ - candidate for candidate in report.valid if candidate.kind.value == "core" + candidate + for candidate in report.valid + if candidate.kind.value == "core" ] + self.assertTrue(core_candidates) + for candidate in core_candidates: - with self.subTest(collector=candidate.metadata.id): - source = candidate.path.read_text(encoding="utf-8") - tree = ast.parse(source, filename=str(candidate.path)) + metadata = candidate.metadata + if metadata is None: + self.fail( + f"valid core candidate {candidate.selection_id} " + "has no metadata" + ) + + with self.subTest(collector=metadata.id): + source = candidate.path.read_text( + encoding="utf-8" + ) + tree = ast.parse( + source, + filename=str(candidate.path), + ) + self.assertFalse( - any(isinstance(node, (ast.Global, ast.Nonlocal)) for node in ast.walk(tree)), + any( + isinstance( + node, + (ast.Global, ast.Nonlocal), + ) + for node in ast.walk(tree) + ), "collectors must not declare shared mutable state", ) - collect = next( - node for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - and node.name == "collect" - ) + + collect: ast.FunctionDef | ast.AsyncFunctionDef | None = None + + for node in ast.walk(tree): + if ( + isinstance( + node, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ), + ) + and node.name == "collect" + ): + collect = node + break + + if collect is None: + self.fail( + f"{metadata.id} does not define collect()" + ) + attributes = { - node.attr for node in ast.walk(collect) if isinstance(node, ast.Attribute) + node.attr + for node in ast.walk(collect) + if isinstance(node, ast.Attribute) } + self.assertIn("workspace", attributes) self.assertIn("artifacts", attributes) self.assertIn("register_file", attributes) - def test_every_core_collector_has_a_stable_output_contract(self) -> None: + def test_every_core_collector_has_a_stable_output_contract( + self, + ) -> None: """Every shipped output has explicit names, formats, package paths, and retention.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + for candidate in report.valid: - if candidate.kind.value != "core" or candidate.metadata is None: + if candidate.kind.value != "core": continue - with self.subTest(collector=candidate.metadata.id): - contract = core_output_contract(candidate.metadata) - self.assertEqual(candidate.metadata.output_media_types, contract.media_types) - self.assertTrue(contract.workspace_patterns) - self.assertTrue(all( - "\\" not in pattern and not pattern.startswith("/") - for pattern in contract.workspace_patterns - )) - self.assertEqual(len(contract.workspace_patterns), len(contract.package_patterns)) - self.assertTrue(all( - path.startswith("evidence/{kind}/core_") for path in contract.package_patterns - )) - self.assertEqual("retained_with_run", contract.retention) - - def test_every_core_collector_obeys_the_typed_lifecycle_contract(self) -> None: + + metadata = candidate.metadata + if metadata is None: + self.fail( + f"valid core candidate {candidate.selection_id} " + "has no metadata" + ) + + with self.subTest(collector=metadata.id): + contract = core_output_contract(metadata) + + self.assertEqual( + metadata.output_media_types, + contract.media_types, + ) + self.assertTrue( + contract.workspace_patterns + ) + self.assertTrue( + all( + "\\" not in pattern + and not pattern.startswith("/") + for pattern in contract.workspace_patterns + ) + ) + self.assertEqual( + len(contract.workspace_patterns), + len(contract.package_patterns), + ) + self.assertTrue( + all( + path.startswith( + "evidence/{kind}/core_" + ) + for path in contract.package_patterns + ) + ) + self.assertEqual( + "retained_with_run", + contract.retention, + ) + + def test_every_core_collector_obeys_the_typed_lifecycle_contract( + self, + ) -> None: """All shipped collectors fail closed on cancellation without platform access or artifacts.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + for candidate in report.valid: - if candidate.kind.value != "core" or candidate.metadata is None: + if candidate.kind.value != "core": continue - with self.subTest(collector=candidate.metadata.id), tempfile.TemporaryDirectory() as temporary: + + metadata = candidate.metadata + if metadata is None: + self.fail( + f"valid core candidate {candidate.selection_id} " + "has no metadata" + ) + + with ( + self.subTest(collector=metadata.id), + tempfile.TemporaryDirectory() as temporary, + ): root = Path(temporary) cancellation = root / ".cancelled" cancellation.touch() + context = CollectorContext( run_id="run-" + "0" * 32, - collector_id=candidate.metadata.id, + collector_id=metadata.id, workspace=root, temporary_directory=root / "tmp", artifacts=_RejectingWriter(), @@ -249,26 +444,63 @@ def test_every_core_collector_obeys_the_typed_lifecycle_contract(self) -> None: settings={}, cancellation_file=cancellation, ) - collector = _load_collector(candidate.path, candidate.expected_class) + + collector = _load_collector( + candidate.path, + candidate.expected_class, + ) + validation = collector.validate(context) self.assertFalse(validation.valid) self.assertTrue(validation.reasons) + prepared = collector.prepare(context) - self.assertTrue(hasattr(prepared, "valid")) + self.assertTrue( + hasattr(prepared, "valid") + ) + result = collector.collect(context) - self.assertIs(CollectorStatus.CANCELLED, result.status) + + self.assertIs( + CollectorStatus.CANCELLED, + result.status, + ) self.assertFalse(result.artifacts) - self.assertIs(result, collector.finalize(context, result)) + + self.assertIs( + result, + collector.finalize( + context, + result, + ), + ) + collector.cleanup(context) - def test_every_registered_artifact_media_type_is_declared(self) -> None: + def test_every_registered_artifact_media_type_is_declared( + self, + ) -> None: """Static artifact calls cannot introduce an undeclared output format.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + for candidate in report.valid: - if candidate.kind.value != "core" or candidate.metadata is None: + if candidate.kind.value != "core": continue - tree = ast.parse(candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path)) + + metadata = candidate.metadata + if metadata is None: + self.fail( + f"valid core candidate {candidate.selection_id} " + "has no metadata" + ) + + tree = ast.parse( + candidate.path.read_text( + encoding="utf-8" + ), + filename=str(candidate.path), + ) register_calls: list[ast.Call] = [] @@ -277,7 +509,10 @@ def test_every_registered_artifact_media_type_is_declared(self) -> None: continue if ( - isinstance(node.func, ast.Attribute) + isinstance( + node.func, + ast.Attribute, + ) and node.func.attr == "register_file" ): register_calls.append(node) @@ -291,149 +526,442 @@ def test_every_registered_artifact_media_type_is_declared(self) -> None: value = keyword.value - if isinstance(value, ast.Constant) and isinstance(value.value, str): - declared_at_calls.add(value.value) + if ( + isinstance( + value, + ast.Constant, + ) + and isinstance( + value.value, + str, + ) + ): + declared_at_calls.add( + value.value + ) + + has_implicit_media_type = any( + not any( + keyword.arg == "media_type" + for keyword in call.keywords + ) + for call in register_calls + ) - if any(not any(keyword.arg == "media_type" for keyword in node.keywords) - for node in register_calls): - declared_at_calls.add("application/octet-stream") - with self.subTest(collector=candidate.metadata.id): + if has_implicit_media_type: + declared_at_calls.add( + "application/octet-stream" + ) + + with self.subTest(collector=metadata.id): self.assertTrue(register_calls) self.assertTrue(declared_at_calls) - self.assertLessEqual(declared_at_calls, set(candidate.metadata.output_media_types)) + self.assertLessEqual( + declared_at_calls, + set(metadata.output_media_types), + ) - def test_migration_documentation_covers_every_supported_bridge(self) -> None: + def test_migration_documentation_covers_every_supported_bridge( + self, + ) -> None: """Public compatibility stays explicit and canonical-output-only.""" project_root = Path(__file__).resolve().parent.parent - migration = (project_root / "MIGRATION.md").read_text(encoding="utf-8") - command_flags = {"default_mode": "default", "performance_check": "performance-check"} + migration = ( + project_root / "MIGRATION.md" + ).read_text(encoding="utf-8") + + command_flags = { + "default_mode": "default", + "performance_check": "performance-check", + } + for flag in LEGACY_MODE_ALIASES: with self.subTest(flag=flag): - self.assertIn(f"`--{command_flags.get(flag, flag.replace('_', '-'))}`", migration) - for bridge in ("CODE/config.ini", "core.integration.legacy_code_outputs", "MODS/"): - self.assertIn(bridge, migration) - self.assertIn("There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/`", migration) - contract = core_output_contract(next( - candidate.metadata for candidate in preflight(project_root).valid - if candidate.metadata and candidate.metadata.id == "core.integration.legacy_code_outputs" - )) - self.assertEqual(("legacy_code/**",), contract.workspace_patterns) - - def test_each_core_module_owns_exactly_one_policy_contract(self) -> None: + self.assertIn( + f"`--{command_flags.get(flag, flag.replace('_', '-'))}`", + migration, + ) + + for bridge in ( + "CODE/config.ini", + "core.integration.legacy_code_outputs", + "MODS/", + ): + self.assertIn( + bridge, + migration, + ) + + self.assertIn( + "There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/`", + migration, + ) + + legacy_metadata = None + + for candidate in preflight(project_root).valid: + metadata = candidate.metadata + if metadata is None: + continue + + if ( + metadata.id + == "core.integration.legacy_code_outputs" + ): + legacy_metadata = metadata + break + + if legacy_metadata is None: + self.fail( + "core.integration.legacy_code_outputs " + "was not discovered during preflight" + ) + + contract = core_output_contract( + legacy_metadata + ) + + self.assertEqual( + ("legacy_code/**",), + contract.workspace_patterns, + ) + + def test_each_core_module_owns_exactly_one_policy_contract( + self, + ) -> None: """Different permission, sensitivity, timeout, or output policies require separate IDs.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) - signatures: dict[str, tuple[object, ...]] = {} + + signatures: dict[ + str, + tuple[object, ...], + ] = {} + for candidate in report.valid: - if candidate.kind.value != "core" or candidate.metadata is None: + if candidate.kind.value != "core": continue - tree = ast.parse(candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path)) - collector_classes: list[ast.ClassDef] = [] + metadata = candidate.metadata + if metadata is None: + self.fail( + f"valid core candidate {candidate.selection_id} " + "has no metadata" + ) + + tree = ast.parse( + candidate.path.read_text( + encoding="utf-8" + ), + filename=str(candidate.path), + ) + + collector_classes: list[ + ast.ClassDef + ] = [] for node in tree.body: - if not isinstance(node, ast.ClassDef): + if not isinstance( + node, + ast.ClassDef, + ): continue inherits_core_collector = any( - ast.unparse(base).endswith("CoreCollector") + ast.unparse(base).endswith( + "CoreCollector" + ) for base in node.bases ) if inherits_core_collector: - collector_classes.append(node) - - with self.subTest(collector=candidate.metadata.id): - self.assertEqual([candidate.expected_class], [node.name for node in collector_classes]) - contract = core_output_contract(candidate.metadata) - signatures[candidate.metadata.id] = ( - candidate.metadata.capabilities, - candidate.metadata.privilege_level, - candidate.metadata.network_access, - candidate.metadata.sensitive_data_categories, - candidate.metadata.timeout_seconds, - candidate.metadata.estimated_cost, + collector_classes.append( + node + ) + + with self.subTest(collector=metadata.id): + self.assertEqual( + [candidate.expected_class], + [ + node.name + for node in collector_classes + ], + ) + + contract = core_output_contract( + metadata + ) + + signatures[metadata.id] = ( + metadata.capabilities, + metadata.privilege_level, + metadata.network_access, + metadata.sensitive_data_categories, + metadata.timeout_seconds, + metadata.estimated_cost, contract.workspace_patterns, contract.media_types, ) - self.assertEqual(len(signatures), len(set(signatures))) - def test_every_core_collector_handles_mocked_windows_platform_responses(self) -> None: + self.assertEqual( + len(signatures), + len(set(signatures)), + ) + + def test_every_core_collector_handles_mocked_windows_platform_responses( + self, + ) -> None: """Each collector returns a typed result when Windows adapters report unavailable data.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) empty_host = root / "empty-host" empty_host.mkdir() + with ExitStack() as mocks: - mocks.enter_context(patch.object( - process_adapter, "run", - return_value=subprocess.CompletedProcess((), 1, "", "Access is denied"), - )) - mocks.enter_context(patch.object(registry_adapter, "OpenKey", side_effect=OSError("missing key"))) - mocks.enter_context(patch.object(windows_api_adapter, "load_library", - side_effect=OSError("API unavailable"))) - mocks.enter_context(patch.object(windows_api_adapter, "is_administrator", return_value=False)) - mocks.enter_context(patch.object(network_adapter, "gethostname", return_value="golden-host")) - mocks.enter_context(patch.object(network_adapter, "gethostbyname", return_value="192.0.2.1")) - mocks.enter_context(patch.object(network_adapter, "getaddrinfo", return_value=[])) - mocks.enter_context(patch.object(network_adapter, "socket", side_effect=PermissionError("denied"))) - mocks.enter_context(patch.object(filesystem_adapter, "home", return_value=empty_host)) - mocks.enter_context(patch.object(filesystem_adapter, "system_drive_root", return_value=empty_host)) - mocks.enter_context(patch.object(filesystem_adapter, "environment_path", return_value=empty_host)) + mocks.enter_context( + patch.object( + process_adapter, + "run", + return_value=subprocess.CompletedProcess( + (), + 1, + "", + "Access is denied", + ), + ) + ) + + mocks.enter_context( + patch.object( + registry_adapter, + "OpenKey", + side_effect=OSError( + "missing key" + ), + ) + ) + + mocks.enter_context( + patch.object( + windows_api_adapter, + "load_library", + side_effect=OSError( + "API unavailable" + ), + ) + ) + + mocks.enter_context( + patch.object( + windows_api_adapter, + "is_administrator", + return_value=False, + ) + ) + + mocks.enter_context( + patch.object( + network_adapter, + "gethostname", + return_value="golden-host", + ) + ) + + mocks.enter_context( + patch.object( + network_adapter, + "gethostbyname", + return_value="192.0.2.1", + ) + ) + + mocks.enter_context( + patch.object( + network_adapter, + "getaddrinfo", + return_value=[], + ) + ) + + mocks.enter_context( + patch.object( + network_adapter, + "socket", + side_effect=PermissionError( + "denied" + ), + ) + ) + + mocks.enter_context( + patch.object( + filesystem_adapter, + "home", + return_value=empty_host, + ) + ) + + mocks.enter_context( + patch.object( + filesystem_adapter, + "system_drive_root", + return_value=empty_host, + ) + ) + + mocks.enter_context( + patch.object( + filesystem_adapter, + "environment_path", + return_value=empty_host, + ) + ) + for candidate in report.valid: - if candidate.kind.value != "core" or candidate.metadata is None: + if candidate.kind.value != "core": continue - with self.subTest(collector=candidate.metadata.id): - workspace = root / candidate.metadata.id.replace(".", "_") - artifact_root = workspace / "published" + + metadata = candidate.metadata + if metadata is None: + self.fail( + f"valid core candidate " + f"{candidate.selection_id} " + "has no metadata" + ) + + with self.subTest( + collector=metadata.id + ): + workspace = ( + root + / metadata.id.replace( + ".", + "_", + ) + ) + + artifact_root = ( + workspace / "published" + ) + workspace.mkdir() artifact_root.mkdir() + context = CollectorContext( run_id="run-" + "0" * 32, - collector_id=candidate.metadata.id, + collector_id=metadata.id, workspace=workspace, - temporary_directory=workspace / "tmp", + temporary_directory=( + workspace / "tmp" + ), artifacts=WorkspaceArtifactWriter( - candidate.metadata.id, + metadata.id, workspace, artifact_root, - candidate.metadata.maximum_output_bytes, - candidate.metadata.maximum_artifact_files, + metadata.maximum_output_bytes, + metadata.maximum_artifact_files, ), logger=_NoopLogger(), settings={}, - cancellation_file=workspace / ".cancelled", + cancellation_file=( + workspace + / ".cancelled" + ), + ) + + collector = _load_collector( + candidate.path, + candidate.expected_class, ) - result = _load_collector(candidate.path, candidate.expected_class).collect(context) - self.assertIn(result.status, set(CollectorStatus)) - def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete(self) -> None: + result = collector.collect( + context + ) + + self.assertIn( + result.status, + set(CollectorStatus), + ) + + def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( + self, + ) -> None: """Minimal and standard stay bounded while deep explicitly owns the exhaustive surface.""" project_root = Path(__file__).resolve().parent.parent - metadata = [ - candidate.metadata for candidate in preflight(project_root).valid - if candidate.kind.value == "core" and candidate.metadata is not None - ] + + metadata = [] + + for candidate in preflight(project_root).valid: + if candidate.kind.value != "core": + continue + + candidate_metadata = candidate.metadata + if candidate_metadata is None: + continue + + metadata.append(candidate_metadata) + memberships = { - profile: {item.id for item in metadata if profile in item.default_profiles} - for profile in ("minimal", "standard", "deep", "offline") + profile: { + item.id + for item in metadata + if profile in item.default_profiles + } + for profile in ( + "minimal", + "standard", + "deep", + "offline", + ) } - self.assertEqual(3, len(memberships["minimal"])) - self.assertEqual(6, len(memberships["standard"])) - self.assertEqual({item.id for item in metadata}, memberships["deep"]) - self.assertLess(memberships["minimal"], memberships["standard"]) - self.assertLess(memberships["standard"], memberships["deep"]) - self.assertEqual(memberships["minimal"], memberships["offline"]) + + self.assertEqual( + 3, + len(memberships["minimal"]), + ) + self.assertEqual( + 6, + len(memberships["standard"]), + ) + self.assertEqual( + { + item.id + for item in metadata + }, + memberships["deep"], + ) + self.assertLess( + memberships["minimal"], + memberships["standard"], + ) + self.assertLess( + memberships["standard"], + memberships["deep"], + ) + self.assertEqual( + memberships["minimal"], + memberships["offline"], + ) self.assertLessEqual( - sum(item.timeout_seconds for item in metadata if item.id in memberships["standard"]), + sum( + item.timeout_seconds + for item in metadata + if item.id + in memberships["standard"] + ), 90, ) - self.assertTrue(all( - not item.sensitive_data_categories - for item in metadata if item.id in memberships["standard"] - )) + self.assertTrue( + all( + not item.sensitive_data_categories + for item in metadata + if item.id + in memberships["standard"] + ) + ) if __name__ == "__main__": diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py index 925df128..1dd3ad4d 100644 --- a/tests/test_windows_integration.py +++ b/tests/test_windows_integration.py @@ -69,19 +69,27 @@ def test_every_shipped_output_contract_publishes_on_windows(self) -> None: """Publish one representative artifact for every canonical core path and MIME contract.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) + with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) + for candidate in report.valid: - if candidate.kind.value != "core" or candidate.metadata is None: + metadata = candidate.metadata + + if candidate.kind.value != "core" or metadata is None: continue - with self.subTest(collector=candidate.metadata.id): - contract = core_output_contract(candidate.metadata) - workspace = root / "work" / candidate.metadata.id.replace(".", "_") - artifact_root = root / "artifacts" / candidate.metadata.id.replace(".", "_") + + with self.subTest(collector=metadata.id): + contract = core_output_contract(metadata) + + workspace = root / "work" / metadata.id.replace(".", "_") + artifact_root = root / "artifacts" / metadata.id.replace(".", "_") + workspace.mkdir(parents=True) artifact_root.mkdir(parents=True) + writer = WorkspaceArtifactWriter( - candidate.metadata.id, + metadata.id, workspace, artifact_root, 1024 * 1024, @@ -89,20 +97,35 @@ def test_every_shipped_output_contract_publishes_on_windows(self) -> None: allowed_relative_paths=contract.workspace_patterns, allowed_media_types=contract.media_types, ) - for pattern, media_type in zip(contract.workspace_patterns, contract.media_types): + + for pattern, media_type in zip( + contract.workspace_patterns, + contract.media_types, + ): parts: list[str] = [] + for part in Path(pattern).parts: if part == "**": parts.extend(("sample", "evidence.bin")) else: parts.append(part.replace("*", "sample")) + source = workspace.joinpath(*parts) source.parent.mkdir(parents=True, exist_ok=True) source.write_bytes(b"golden evidence\n") - artifact = writer.register_file(source, media_type=media_type) - self.assertIn(artifact.relative_path, { - f"{candidate.metadata.id.replace('.', '_')}/{source.relative_to(workspace).as_posix()}" - }) + + artifact = writer.register_file( + source, + media_type=media_type, + ) + + self.assertIn( + artifact.relative_path, + { + f"{metadata.id.replace('.', '_')}/" + f"{source.relative_to(workspace).as_posix()}" + }, + ) if __name__ == "__main__": From 437e4e084daf76228e4185044a06bb1aa8cd6c12 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 02:16:26 +0300 Subject: [PATCH 231/316] fix: unresolved ref in __init__.py fixed Signed-off-by: DefinetlyNotAI --- logicytics/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 0c80d083..a23374ce 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -1,5 +1,6 @@ """Logicytics v4 public engine API.""" - +from logicytics.api import run_collection, read_artifact, query_run, open_artifact, plan_run, load_configuration, \ + RunSnapshot, CollectorSnapshot, CollectorFailureSnapshot from logicytics.contracts import ( CONTRACT_VERSION, Capability, From c663b73d7345c04f6ceba799b23fe4ca265be95f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 02:18:41 +0300 Subject: [PATCH 232/316] chore: declutter root by moving 2 md files to .github Signed-off-by: DefinetlyNotAI --- CREDITS.md => .github/CREDITS.md | 94 ++++++++++++++++---------------- DCO.md => .github/DCO.md | 0 .github/PULL_REQUEST_TEMPLATE.md | 2 +- CONTRIBUTING.md | 2 +- 4 files changed, 49 insertions(+), 49 deletions(-) rename CREDITS.md => .github/CREDITS.md (97%) rename DCO.md => .github/DCO.md (100%) diff --git a/CREDITS.md b/.github/CREDITS.md similarity index 97% rename from CREDITS.md rename to .github/CREDITS.md index c66fcfec..3b25275d 100644 --- a/CREDITS.md +++ b/.github/CREDITS.md @@ -1,47 +1,47 @@ -# CREDITS - -This project is built on the shoulders of giants and inspired by the work of many talented individuals and -organizations. We acknowledge their contributions and are grateful for the knowledge and tools they have shared. - - - Contributor GitHub Profiles - - - - -## 👨‍💻 Coders Credits 👨‍💻 - -### Wifi-Stealer.py, bluetooth_details.py and bluetooth_logger.py by ski-sketch - -Created Wi-Fi Password Stealer using python -The sole creator of the code of wifi-stealer -Also created bluetooth_details.py and bluetooth_logger.py -which are used to get the details of the bluetooth devices -and log the details of the bluetooth devices respectively - -- [ski-sketch](https://github.com/ski-sketch) - -## 🛠️ Refactorers Credits 🛠️ - -Until Now, no one. Become a contributor and help us spread the word. - -## 🔨 Enhancers Credits 🔨 - -Until Now, no one. Become a contributor and help us spread the word. - -## 🐛 Bug bounty credits 🐛 - -### Found development bug - -Found and attempted fix of 2 bugs: Zipping name error - `--dev` flag loop - -- [ski-sketch](https://github.com/ski-sketch) - -# Acknowledgments - -This project would not be possible without the contributions and inspirations from the above-mentioned individuals and -organizations. We are deeply grateful for their work and the community that supports it. +# CREDITS + +This project is built on the shoulders of giants and inspired by the work of many talented individuals and +organizations. We acknowledge their contributions and are grateful for the knowledge and tools they have shared. + + + Contributor GitHub Profiles + + + + +## 👨‍💻 Coders Credits 👨‍💻 + +### Wifi-Stealer.py, bluetooth_details.py and bluetooth_logger.py by ski-sketch + +Created Wi-Fi Password Stealer using python +The sole creator of the code of wifi-stealer +Also created bluetooth_details.py and bluetooth_logger.py +which are used to get the details of the bluetooth devices +and log the details of the bluetooth devices respectively + +- [ski-sketch](https://github.com/ski-sketch) + +## 🛠️ Refactorers Credits 🛠️ + +Until Now, no one. Become a contributor and help us spread the word. + +## 🔨 Enhancers Credits 🔨 + +Until Now, no one. Become a contributor and help us spread the word. + +## 🐛 Bug bounty credits 🐛 + +### Found development bug + +Found and attempted fix of 2 bugs: Zipping name error - `--dev` flag loop + +- [ski-sketch](https://github.com/ski-sketch) + +# Acknowledgments + +This project would not be possible without the contributions and inspirations from the above-mentioned individuals and +organizations. We are deeply grateful for their work and the community that supports it. diff --git a/DCO.md b/.github/DCO.md similarity index 100% rename from DCO.md rename to .github/DCO.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 529f313b..5368c4be 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,7 +24,7 @@ - [ ] Collector metadata, cancellation, isolation, and artifact registration remain valid. - [ ] Configuration, output, migration, release, and feature-status docs are updated when affected. - [ ] No generated evidence, credentials, private data, caches, or unrelated changes are included. -- [ ] I agree to the [Developer Certificate of Origin](../DCO.md) and repository license. +- [ ] I agree to the [Developer Certificate of Origin](DCO.md) and repository license. ## Related issues diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 282812e5..89fe4db7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,7 +170,7 @@ Pull requests should: - include tests and documentation proportional to the changed contract; - preserve unrelated work and never include generated evidence or secrets; - pass the complete verification gates above; and -- comply with the [Developer Certificate of Origin](DCO.md), +- comply with the [Developer Certificate of Origin](.github/DCO.md), [Code of Conduct](CODE_OF_CONDUCT.md), and repository license. By contributing code, you agree to license it under the [MIT License](LICENSE). From 6fe4f727cfa2a71cc316e40ffdb658085c9e47f1 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 02:32:08 +0300 Subject: [PATCH 233/316] ci: enforce conventional commit messages --- .github/script/validate-commit-msg.sh | 49 +++++++++++++++++++++++++++ .pre-commit-config.yaml | 13 ++++++- 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 .github/script/validate-commit-msg.sh diff --git a/.github/script/validate-commit-msg.sh b/.github/script/validate-commit-msg.sh new file mode 100644 index 00000000..6bcad578 --- /dev/null +++ b/.github/script/validate-commit-msg.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env sh + +set -eu + +commit_msg_file="$1" +first_line="$(head -n 1 "$commit_msg_file")" + +pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([A-Za-z0-9._/-]+\))?!?: .+$' + +if printf '%s\n' "$first_line" | grep -Eq "$pattern"; then + exit 0 +fi + +cat >&2 <<'EOF' + +Invalid commit message. + +Expected Conventional Commits format: + + : + (): + !: + ()!: + +Allowed types: + + build + chore + ci + docs + feat + fix + perf + refactor + revert + style + test + +Examples: + + feat: add artifact compression + fix(cli): handle missing configuration + refactor(core): simplify collector discovery + docs: update setup instructions + feat(api)!: remove legacy endpoint + +EOF + +exit 1 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c46e656..a0397b6c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,12 +3,23 @@ repos: rev: v8.16.3 hooks: - id: gitleaks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace + - repo: https://github.com/pylint-dev/pylint - rev: v2.17.2 + rev: v4.0.2 hooks: - id: pylint + + - repo: local + hooks: + - id: conventional-commit-message + name: Validate conventional commit message + entry: .github/script/validate-commit-msg.sh + language: script + stages: + - commit-msg From c92b01a9e6bd5283c1c6d3588935880b20b34d00 Mon Sep 17 00:00:00 2001 From: Shahm Najeeb Date: Sat, 5 Sep 2026 02:32:37 +0300 Subject: [PATCH 234/316] chore: reformatted via pre-hooks Signed-off-by: DefinetlyNotAI --- .bandit | 2 +- .github/CODEOWNERS | 2 +- .github/DCO.md | 2 +- .github/dependabot.yml | 2 +- .mailmap | 2 +- .pre-commit-config.yaml | 6 ++++-- docs/README.md | 2 +- logicytics/.gitkeep | 1 - plugins/.gitkeep | 1 - 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.bandit b/.bandit index 0b01d12b..35460139 100644 --- a/.bandit +++ b/.bandit @@ -1,3 +1,3 @@ # FILE: .bandit [bandit] -skips: ['B607', 'B605'] \ No newline at end of file +skips: ['B607', 'B605'] diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 27373b05..54ebb4e6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @DefinetlyNotAI \ No newline at end of file +* @DefinetlyNotAI diff --git a/.github/DCO.md b/.github/DCO.md index c3a29727..6a2e3c60 100644 --- a/.github/DCO.md +++ b/.github/DCO.md @@ -32,5 +32,5 @@ By making a contribution to this project, I certify that: are public and that a record of the contribution (including all personal information I submit with it, including my sign-off) is maintained indefinitely and may be redistributed consistent with - this project or the open source license(s) involved. + this project or the open source license(s) involved. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 554c5ed8..dd0fa13a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -18,4 +18,4 @@ updates: interval: daily labels: - "type/Dependencies" - - "type/Github Actions" \ No newline at end of file + - "type/Github Actions" diff --git a/.mailmap b/.mailmap index f5b83505..3e9b544f 100644 --- a/.mailmap +++ b/.mailmap @@ -1 +1 @@ -Shahm Najeeb \ No newline at end of file +Shahm Najeeb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0397b6c..4a1f6468 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,9 +15,11 @@ repos: hooks: - id: pylint - - repo: local + - # noinspection YAMLSchemaValidation + repo: local hooks: - - id: conventional-commit-message + - # noinspection YAMLSchemaValidation + id: conventional-commit-message name: Validate conventional commit message entry: .github/script/validate-commit-msg.sh language: script diff --git a/docs/README.md b/docs/README.md index 3a9961c4..bff8f5c8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,4 +4,4 @@ Please understand this is NOT the projects docs, but rather the v3 → v4 system This folder may be removed/replaced in the future -The real projects docs are found in the Repo's Wiki \ No newline at end of file +The real projects docs are found in the Repo's Wiki diff --git a/logicytics/.gitkeep b/logicytics/.gitkeep index 8b137891..e69de29b 100644 --- a/logicytics/.gitkeep +++ b/logicytics/.gitkeep @@ -1 +0,0 @@ - diff --git a/plugins/.gitkeep b/plugins/.gitkeep index 8b137891..e69de29b 100644 --- a/plugins/.gitkeep +++ b/plugins/.gitkeep @@ -1 +0,0 @@ - From 8be342c08e106d64e9545492f3be9c7a2d0ae3e8 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 02:50:29 +0300 Subject: [PATCH 235/316] refactor: centralize ctypes bindings Signed-off-by: DefinetlyNotAI --- .pylintrc | 16 +++ core/process/memory_map.py | 13 +- core/storage/volume_details.py | 40 ++++-- logicytics/ctypes_collector.py | 217 ++++++++++++++++++++++++++++++++ tests/test_platform_adapters.py | 18 ++- tests/test_runtime.py | 26 ++-- 6 files changed, 296 insertions(+), 34 deletions(-) create mode 100644 .pylintrc create mode 100644 logicytics/ctypes_collector.py diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 00000000..55dca49a --- /dev/null +++ b/.pylintrc @@ -0,0 +1,16 @@ +[MESSAGES CONTROL] + +disable= + line-too-long, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + too-few-public-methods, + too-many-arguments, + too-many-positional-arguments, + too-many-locals, + too-many-return-statements, + too-many-branches, + too-many-statements, + invalid-name, + protected-access diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 5e6d3e61..0001e757 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -9,6 +9,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.ctypes_collector import ProcessMemoryCounters from logicytics.platform_adapters import filesystem_adapter, windows_api_adapter @@ -18,14 +19,6 @@ class _MemoryBasicInformation(ctypes.Structure): ("State", wintypes.DWORD), ("Protect", wintypes.DWORD), ("Type", wintypes.DWORD)] -class _ProcessMemoryCounters(ctypes.Structure): - _fields_ = [("cb", wintypes.DWORD), ("PageFaultCount", wintypes.DWORD), ("PeakWorkingSetSize", ctypes.c_size_t), - ("WorkingSetSize", ctypes.c_size_t), ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), - ("QuotaPagedPoolUsage", ctypes.c_size_t), ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), - ("QuotaNonPagedPoolUsage", ctypes.c_size_t), ("PagefileUsage", ctypes.c_size_t), - ("PeakPagefileUsage", ctypes.c_size_t)] - - def _permissions(protection: int) -> str: """Return a readable Windows page-protection summary.""" values = {0x02: "read", 0x04: "read_write", 0x08: "write_copy", 0x20: "execute_read", 0x40: "execute_read_write", @@ -127,7 +120,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: errors=(str(error),), ) process = kernel32.GetCurrentProcess() - counters = _ProcessMemoryCounters() + counters = ProcessMemoryCounters() psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), ctypes.sizeof(counters)) memory = _MemoryBasicInformation() address = 0 @@ -140,7 +133,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not queried or memory.RegionSize == 0: break base = ctypes.cast(memory.BaseAddress, ctypes.c_void_p).value or address - readable = memory.State == 0x1000 and not (memory.Protect & 0x101) + readable = memory.State == 0x1000 and not memory.Protect & 0x101 if readable: base_address = int(base) diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index fe5e3cbc..ace08ad0 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -4,11 +4,12 @@ import ctypes import json -from ctypes import wintypes from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.ctypes_collector import get_volume_information, get_disk_free_space, ularge_integer, \ + create_unicode_buffer, dword from logicytics.platform_adapters import windows_api_adapter _DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", @@ -26,19 +27,32 @@ def _volume_details() -> list[dict[str, int | str]]: if not mask & (1 << offset): continue root = f"{chr(ord('A') + offset)}:\\" - available = ctypes.c_ulonglong() - total = ctypes.c_ulonglong() - free = ctypes.c_ulonglong() - if not kernel32.GetDiskFreeSpaceExW(root, ctypes.byref(available), ctypes.byref(total), ctypes.byref(free)): + + available = ularge_integer() + total = ularge_integer() + free = ularge_integer() + + if not get_disk_free_space( + root, + available, + total, + free, + ): continue - label = ctypes.create_unicode_buffer(261) - filesystem = ctypes.create_unicode_buffer(261) - serial = wintypes.DWORD() - maximum_component_length = wintypes.DWORD() - flags = wintypes.DWORD() - information_available = kernel32.GetVolumeInformationW( - root, label, len(label), ctypes.byref(serial), ctypes.byref(maximum_component_length), ctypes.byref(flags), - filesystem, len(filesystem) + + label = create_unicode_buffer(261) + filesystem = create_unicode_buffer(261) + serial = dword() + maximum_component_length = dword() + flags = dword() + + information_available = get_volume_information( + root, + label, + serial, + maximum_component_length, + flags, + filesystem, ) volumes.append({ "root": root, diff --git a/logicytics/ctypes_collector.py b/logicytics/ctypes_collector.py new file mode 100644 index 00000000..ef5f3cd2 --- /dev/null +++ b/logicytics/ctypes_collector.py @@ -0,0 +1,217 @@ +"""Centralized, typed Windows ctypes bindings used by Logicytics.""" + +from __future__ import annotations + +import ctypes +import sys +from ctypes import wintypes +from typing import Final, TypeAlias + +if sys.platform != "win32": + raise RuntimeError("ctypes_collector is only supported on Windows") + +DWORD: TypeAlias = wintypes.DWORD +HANDLE: TypeAlias = wintypes.HANDLE +BOOL: TypeAlias = wintypes.BOOL +ULONG: TypeAlias = ctypes.c_ulong +SIZE_T: TypeAlias = ctypes.c_size_t +ULARGE_INTEGER: TypeAlias = ctypes.c_ulonglong + +LPDWORD: TypeAlias = ctypes.POINTER(DWORD) +LPULARGE_INTEGER: TypeAlias = ctypes.POINTER(ULARGE_INTEGER) + +PROCESS_QUERY_LIMITED_INFORMATION: Final = 0x1000 +STILL_ACTIVE: Final = 259 + + +class ProcessMemoryCounters(ctypes.Structure): + """Windows PROCESS_MEMORY_COUNTERS structure.""" + + _fields_ = [ + ("cb", DWORD), + ("PageFaultCount", DWORD), + ("PeakWorkingSetSize", SIZE_T), + ("WorkingSetSize", SIZE_T), + ("QuotaPeakPagedPoolUsage", SIZE_T), + ("QuotaPagedPoolUsage", SIZE_T), + ("QuotaPeakNonPagedPoolUsage", SIZE_T), + ("QuotaNonPagedPoolUsage", SIZE_T), + ("PagefileUsage", SIZE_T), + ("PeakPagefileUsage", SIZE_T), + ] + + +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + +_OpenProcess = kernel32.OpenProcess +_OpenProcess.argtypes = [ + DWORD, + BOOL, + DWORD, +] +_OpenProcess.restype = HANDLE + +_GetExitCodeProcess = kernel32.GetExitCodeProcess +_GetExitCodeProcess.argtypes = [ + HANDLE, + LPDWORD, +] +_GetExitCodeProcess.restype = BOOL + +_CloseHandle = kernel32.CloseHandle +_CloseHandle.argtypes = [ + HANDLE, +] +_CloseHandle.restype = BOOL + +_GetVolumeInformationW = kernel32.GetVolumeInformationW +_GetVolumeInformationW.argtypes = [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + DWORD, + LPDWORD, + LPDWORD, + LPDWORD, + wintypes.LPWSTR, + DWORD, +] +_GetVolumeInformationW.restype = BOOL + +_GetDiskFreeSpaceExW = kernel32.GetDiskFreeSpaceExW +_GetDiskFreeSpaceExW.argtypes = [ + wintypes.LPCWSTR, + LPULARGE_INTEGER, + LPULARGE_INTEGER, + LPULARGE_INTEGER, +] +_GetDiskFreeSpaceExW.restype = BOOL + + +def open_process( + process_id: int, + *, + access: int = PROCESS_QUERY_LIMITED_INFORMATION, + inherit_handle: bool = False, +) -> HANDLE | None: + """Open a Windows process and return its handle, or None on failure.""" + + handle = _OpenProcess( + access, + inherit_handle, + process_id, + ) + + if not handle: + return None + + return handle + + +def get_exit_code_process(handle: HANDLE) -> int | None: + """Return a process exit code, or None if querying it failed.""" + + exit_code = DWORD() + + if not _GetExitCodeProcess( + handle, + ctypes.byref(exit_code), + ): + return None + + return int(exit_code.value) + + +def close_handle(handle: HANDLE) -> bool: + """Close a Windows kernel handle.""" + + return bool(_CloseHandle(handle)) + + +def get_volume_information( + root: str, + label: ctypes.Array[ctypes.c_wchar], + serial: DWORD, + maximum_component_length: DWORD, + flags: DWORD, + filesystem: ctypes.Array[ctypes.c_wchar], +) -> bool: + """Populate volume metadata for a filesystem root.""" + + return bool( + _GetVolumeInformationW( + root, + label, + len(label), + ctypes.byref(serial), + ctypes.byref(maximum_component_length), + ctypes.byref(flags), + filesystem, + len(filesystem), + ) + ) + + +def get_disk_free_space( + root: str, + available: ULARGE_INTEGER, + total: ULARGE_INTEGER, + free: ULARGE_INTEGER, +) -> bool: + """Populate disk space counters for a filesystem root.""" + + return bool( + _GetDiskFreeSpaceExW( + root, + ctypes.byref(available), + ctypes.byref(total), + ctypes.byref(free), + ) + ) + + +def create_unicode_buffer(size: int) -> ctypes.Array[ctypes.c_wchar]: + """Create a mutable Windows Unicode buffer.""" + + return ctypes.create_unicode_buffer(size) + + +def dword(value: int = 0) -> DWORD: + """Create a Windows DWORD value.""" + + return DWORD(value) + + +def ulong(value: int = 0) -> ULONG: + """Create an unsigned long ctypes value.""" + + return ULONG(value) + + +def ularge_integer(value: int = 0) -> ULARGE_INTEGER: + """Create an unsigned 64-bit Windows integer.""" + + return ULARGE_INTEGER(value) + + +__all__ = [ + "BOOL", + "DWORD", + "HANDLE", + "LPDWORD", + "LPULARGE_INTEGER", + "PROCESS_QUERY_LIMITED_INFORMATION", + "ProcessMemoryCounters", + "SIZE_T", + "STILL_ACTIVE", + "ULARGE_INTEGER", + "ULONG", + "close_handle", + "create_unicode_buffer", + "dword", + "get_disk_free_space", + "get_exit_code_process", + "get_volume_information", + "open_process", + "ularge_integer", + "ulong", +] diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 2c5a24d4..29e3204f 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -248,14 +248,26 @@ def test_environment_inspection_uses_only_mockable_platform_adapters(self) -> No self.assertEqual("RemoteSigned", report.powershell_execution_policy) self.assertEqual("C:/Windows/PowerShell.exe", run.call_args.args[0][0]) - def test_core_collectors_cannot_load_win32_libraries_directly(self) -> None: + def test_core_collectors_cannot_use_ctypes_directly(self) -> None: project_root = Path(__file__).resolve().parent.parent + + forbidden_tokens = ( + "import ctypes", + "from ctypes import", + "ctypes.WinDLL(", + "ctypes.windll.", + "ctypes.cdll.", + ) + offenders = [ str(path.relative_to(project_root)) for path in (project_root / "core").rglob("*.py") - if any(token in path.read_text(encoding="utf-8") - for token in ("ctypes.WinDLL(", "ctypes.windll.")) + if any( + token in path.read_text(encoding="utf-8") + for token in forbidden_tokens + ) ] + self.assertEqual([], offenders) def test_executable_resolution_uses_the_platform_boundary(self) -> None: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 1bfd12e4..79976ca8 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,6 +1,5 @@ from __future__ import annotations -import ctypes import json import os import tempfile @@ -9,7 +8,6 @@ from pathlib import Path from unittest.mock import patch -from fixtures.collectors import COLLECTOR, delayed_collector_source from logicytics.configuration import ( default_config, ) @@ -17,9 +15,16 @@ Capability, RunRequest, ) +from logicytics.ctypes_collector import ( + open_process, + get_exit_code_process, + STILL_ACTIVE, + close_handle +) from logicytics.discovery import preflight from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR, delayed_collector_source class RuntimeTests(unittest.TestCase): @@ -518,14 +523,19 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer encoding="ascii" ) ) - handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, child_pid) - if handle: + + handle = open_process(child_pid) + if handle is not None: try: - exit_code = ctypes.c_ulong() - self.assertTrue(ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code))) - self.assertNotEqual(259, exit_code.value, "collector subprocess survived worker termination") + exit_code = get_exit_code_process(handle) + self.assertIsNotNone(exit_code) + self.assertNotEqual( + STILL_ACTIVE, + exit_code, + "collector subprocess survived worker termination", + ) finally: - ctypes.windll.kernel32.CloseHandle(handle) + close_handle(handle) self.assertEqual("failed", records["core.system.a_tree"].status) self.assertEqual("timeout_exceeded", records["core.system.a_tree"].termination_reason) From 07358663f70fa5e38682d8ad4f9e4363ddcce1b6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 03:00:04 +0300 Subject: [PATCH 236/316] chore: removed pylint Signed-off-by: DefinetlyNotAI --- .pre-commit-config.yaml | 5 ----- .pylintrc | 16 ---------------- 2 files changed, 21 deletions(-) delete mode 100644 .pylintrc diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4a1f6468..836af996 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,11 +10,6 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/pylint-dev/pylint - rev: v4.0.2 - hooks: - - id: pylint - - # noinspection YAMLSchemaValidation repo: local hooks: diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 55dca49a..00000000 --- a/.pylintrc +++ /dev/null @@ -1,16 +0,0 @@ -[MESSAGES CONTROL] - -disable= - line-too-long, - missing-module-docstring, - missing-class-docstring, - missing-function-docstring, - too-few-public-methods, - too-many-arguments, - too-many-positional-arguments, - too-many-locals, - too-many-return-statements, - too-many-branches, - too-many-statements, - invalid-name, - protected-access From ebe31019f46a17f93242187090b2d180dd379f0c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 03:02:02 +0300 Subject: [PATCH 237/316] fix: invalid import path (forgot tests. prefix) some minor PEP warns as well Signed-off-by: DefinetlyNotAI --- logicytics/ctypes_collector.py | 4 +- tests/test_artifact_writer.py | 2 +- tests/test_collector_cancellation.py | 9 +-- tests/test_configuration.py | 2 +- tests/test_mods.py | 2 +- tests/test_packaging.py | 2 +- tests/test_planning.py | 2 +- tests/test_preflight.py | 2 +- tests/test_public_api.py | 93 +++++++++++++++++++++++----- tests/test_scheduler.py | 2 +- tests/test_shipped_collectors.py | 19 +----- tests/test_worker_security.py | 2 +- 12 files changed, 94 insertions(+), 47 deletions(-) diff --git a/logicytics/ctypes_collector.py b/logicytics/ctypes_collector.py index ef5f3cd2..c9d5b1bd 100644 --- a/logicytics/ctypes_collector.py +++ b/logicytics/ctypes_collector.py @@ -17,8 +17,8 @@ SIZE_T: TypeAlias = ctypes.c_size_t ULARGE_INTEGER: TypeAlias = ctypes.c_ulonglong -LPDWORD: TypeAlias = ctypes.POINTER(DWORD) -LPULARGE_INTEGER: TypeAlias = ctypes.POINTER(ULARGE_INTEGER) +LPDWORD = ctypes.POINTER(DWORD) +LPULARGE_INTEGER = ctypes.POINTER(ULARGE_INTEGER) PROCESS_QUERY_LIMITED_INFORMATION: Final = 0x1000 STILL_ACTIVE: Final = 259 diff --git a/tests/test_artifact_writer.py b/tests/test_artifact_writer.py index 79aec9f6..ff327ffa 100644 --- a/tests/test_artifact_writer.py +++ b/tests/test_artifact_writer.py @@ -7,7 +7,6 @@ from pathlib import Path from unittest.mock import patch -from fixtures.collectors import COLLECTOR from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.configuration import ( default_config, @@ -20,6 +19,7 @@ from logicytics.errors import ArtifactError from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR class ArtifactWriterTests(unittest.TestCase): diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index 7248b094..2d4280ba 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -332,8 +332,7 @@ def finish_after_cancellation( [ path for path in context.workspace.rglob("*") - if path.is_file() - and path != cancellation_file + if path.is_file() and path != cancellation_file ], ) @@ -404,8 +403,7 @@ def cancel_system_copy( [ path for path in system_context.workspace.rglob("*") - if path.is_file() - and path != system_cancellation + if path.is_file() and path != system_cancellation ], ) @@ -622,8 +620,7 @@ def fake_windll( [ path for path in memory_context.workspace.rglob("*") - if path.is_file() - and path != memory_cancellation + if path.is_file() and path != memory_cancellation ], ) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index a03e7746..24121733 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Any, cast -from fixtures.collectors import COLLECTOR from logicytics.configuration import ( default_config, load_config, @@ -19,6 +18,7 @@ from logicytics.errors import PlanError from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR class ConfigurationTests(unittest.TestCase): diff --git a/tests/test_mods.py b/tests/test_mods.py index af04ede9..9a25f8e4 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -8,7 +8,6 @@ import zipfile from pathlib import Path -from fixtures.collectors import mod_metadata from logicytics.cli import cli_methods from logicytics.configuration import default_config from logicytics.contracts import Capability, RunRequest @@ -16,6 +15,7 @@ from logicytics.errors import PlanError, PreflightError from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import mod_metadata class ModTests(unittest.TestCase): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 301a7742..6707eeb5 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -12,7 +12,6 @@ from typing import Any, Callable, cast from unittest.mock import patch -from fixtures.collectors import COLLECTOR, delayed_collector_source from logicytics import packaging from logicytics.cli import cli_methods from logicytics.configuration import ( @@ -27,6 +26,7 @@ from logicytics.packaging import package_run from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR, delayed_collector_source class PackagingTests(unittest.TestCase): diff --git a/tests/test_planning.py b/tests/test_planning.py index 08209313..eb6a2b92 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -8,7 +8,6 @@ from pathlib import Path from unittest.mock import patch -from fixtures.collectors import COLLECTOR, delayed_collector_source from logicytics.cli import CLI, cli_methods, main from logicytics.configuration import ( default_config, @@ -30,6 +29,7 @@ from logicytics.planner import BUILTIN_PROFILES, build_plan from logicytics.platform_adapters import ProcessAdapter from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR, delayed_collector_source class PlanningTests(unittest.TestCase): diff --git a/tests/test_preflight.py b/tests/test_preflight.py index cb8dddc2..518c47f0 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -8,7 +8,6 @@ from pathlib import Path from unittest.mock import patch -from fixtures.collectors import COLLECTOR, plugin_collector_source, delayed_collector_source from logicytics import ( discovery, ) @@ -26,6 +25,7 @@ from logicytics.planner import build_plan from logicytics.platform_adapters import ProcessAdapter from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR, plugin_collector_source, delayed_collector_source class PreflightTests(unittest.TestCase): diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 92a42a54..fddc0b0b 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -9,9 +9,9 @@ import unittest from dataclasses import FrozenInstanceError from pathlib import Path +from typing import Iterable from unittest.mock import patch -from fixtures.collectors import COLLECTOR, plugin_collector_source from logicytics import ( load_configuration, open_artifact, @@ -25,7 +25,9 @@ RunRequest, RunStatus, ) +from logicytics.discovery import CollectorCandidate from logicytics.errors import ArtifactError, PlanError, PreflightError +from tests.fixtures.collectors import COLLECTOR, plugin_collector_source class PublicApiTests(unittest.TestCase): @@ -60,11 +62,23 @@ def test_public_package_import_does_not_start_runtime_or_create_files(self) -> N def test_public_planning_is_read_only_bounded_and_keeps_plugins_opt_in(self) -> None: """The application planner validates configuration and extensions without starting work.""" + + def collector_ids(collectors: Iterable[CollectorCandidate]) -> list[str]: + ids: list[str] = [] + + for item in collectors: + self.assertIsNotNone(item.metadata) + assert item.metadata is not None + ids.append(item.metadata.id) + + return ids + with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) collector_path = root / "core" / "system" / "system_info.py" collector_path.parent.mkdir(parents=True) collector_path.write_text(COLLECTOR, encoding="utf-8") + plugin_path = root / "plugins" / "example_plugin.py" plugin_path.parent.mkdir() plugin_path.write_text( @@ -73,39 +87,88 @@ def test_public_planning_is_read_only_bounded_and_keeps_plugins_opt_in(self) -> .replace("core.system.system_info", "plugin.example_plugin"), encoding="utf-8", ) + (root / "settings.json").write_text( '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', encoding="utf-8", ) + configuration = load_configuration(root, "settings.json") - standard = plan_run(root, RunRequest(max_workers=1), configuration=configuration) + + standard = plan_run( + root, + RunRequest(max_workers=1), + configuration=configuration, + ) selected = plan_run( root, RunRequest(max_workers=1, include=("plugin.example_plugin",)), configuration=configuration, ) - self.assertEqual(["core.system.system_info"], [item.metadata.id for item in standard.collectors]) + self.assertEqual( + ["core.system.system_info"], + collector_ids(standard.collectors), + ) + self.assertEqual( ["core.system.system_info", "plugin.example_plugin"], - [item.metadata.id for item in selected.collectors], + collector_ids(selected.collectors), ) + with self.assertRaisesRegex(PlanError, "maximum_workers"): - plan_run(root, RunRequest(max_workers=3), configuration=configuration) + plan_run( + root, + RunRequest(max_workers=3), + configuration=configuration, + ) + with self.assertRaisesRegex(PlanError, "either configuration or config_path"): - plan_run(root, RunRequest(max_workers=1), configuration=configuration, config_path="settings.json") - plugin_path.write_text('"""Invalid optional plugin."""\n', encoding="utf-8") - quarantined = plan_run(root, RunRequest(max_workers=1), configuration=configuration) - self.assertEqual(["core.system.system_info"], [item.metadata.id for item in quarantined.collectors]) + plan_run( + root, + RunRequest(max_workers=1), + configuration=configuration, + config_path="settings.json", + ) + + plugin_path.write_text( + '"""Invalid optional plugin."""\n', + encoding="utf-8", + ) + + quarantined = plan_run( + root, + RunRequest(max_workers=1), + configuration=configuration, + ) + + self.assertEqual( + ["core.system.system_info"], + collector_ids(quarantined.collectors), + ) + with self.assertRaises(PreflightError): plan_run( root, - RunRequest(max_workers=1, include=("plugin.example_plugin",)), + RunRequest( + max_workers=1, + include=("plugin.example_plugin",), + ), configuration=configuration, ) - collector_path.write_text('"""Invalid shipped core collector."""\n', encoding="utf-8") + + collector_path.write_text( + '"""Invalid shipped core collector."""\n', + encoding="utf-8", + ) + with self.assertRaises(PreflightError): - plan_run(root, RunRequest(max_workers=1), configuration=configuration) + plan_run( + root, + RunRequest(max_workers=1), + configuration=configuration, + ) + self.assertFalse(configuration.runtime.output_root.exists()) def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> None: @@ -144,9 +207,9 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration), ) with self.assertRaises(FrozenInstanceError): - snapshot.status = RunStatus.FAILED + setattr(snapshot, "status", RunStatus.FAILED) with self.assertRaises(FrozenInstanceError): - collector.status = "failed" + setattr(collector, "status", "failed") def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration( self, @@ -184,6 +247,8 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat self.fail("failed collector must expose duration_seconds") self.assertGreaterEqual(duration_seconds, 0) + self.assertIsNotNone(collector.summary) + assert collector.summary is not None self.assertIn("worker crashed", collector.summary) self.assertTrue( any( diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f61c7478..6ce64831 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -4,7 +4,6 @@ import unittest from pathlib import Path -from fixtures.collectors import delayed_collector_source from logicytics.cli import cli_methods from logicytics.configuration import ( default_config, @@ -16,6 +15,7 @@ from logicytics.discovery import preflight from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import delayed_collector_source class SchedulerTests(unittest.TestCase): diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 4a65be92..73db1ffd 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -526,16 +526,7 @@ def test_every_registered_artifact_media_type_is_declared( value = keyword.value - if ( - isinstance( - value, - ast.Constant, - ) - and isinstance( - value.value, - str, - ) - ): + if isinstance(value, ast.Constant) and isinstance(value.value, str): declared_at_calls.add( value.value ) @@ -835,13 +826,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( with self.subTest( collector=metadata.id ): - workspace = ( - root - / metadata.id.replace( - ".", - "_", - ) - ) + workspace = root / metadata.id.replace(".", "_") artifact_root = ( workspace / "published" diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index cc4a7caf..9e4e5e5f 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -5,7 +5,6 @@ import unittest from pathlib import Path -from fixtures.collectors import COLLECTOR, delayed_collector_source from logicytics.configuration import ( default_config, ) @@ -16,6 +15,7 @@ from logicytics.discovery import preflight from logicytics.planner import build_plan from logicytics.runtime import RunSupervisor +from tests.fixtures.collectors import COLLECTOR, delayed_collector_source class WorkerSecurityTests(unittest.TestCase): From bdf8a52a5fe2ea3f72e0edfc974ca2d510f8e21f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 03:14:29 +0300 Subject: [PATCH 238/316] fix: unresolved references and type fixes per PEP Signed-off-by: DefinetlyNotAI --- logicytics/planner.py | 16 ++- tests/test_configuration_documentation.py | 3 + tests/test_flow_matrix.py | 11 +- tests/test_golden_outputs.py | 26 +++- tests/test_platform_adapters.py | 56 +++++++-- tests/test_preflight.py | 8 +- tests/test_running_processes_collector.py | 2 +- tests/test_runtime.py | 141 ++++++++++++++++++---- tests/test_scheduler.py | 25 +++- tests/test_wmic_inventory_collector.py | 21 ++-- 10 files changed, 244 insertions(+), 65 deletions(-) diff --git a/logicytics/planner.py b/logicytics/planner.py index dcbcf716..3d4bd657 100644 --- a/logicytics/planner.py +++ b/logicytics/planner.py @@ -162,11 +162,17 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if missing_capabilities: required = ", ".join(sorted(capability.value for capability in missing_capabilities)) raise PlanError(f"{candidate.metadata.id} requires unapproved capabilities: {required}") - elevated_collectors = sorted( - candidate.metadata.id - for candidate in selected.values() - if candidate.metadata is not None and Capability.ELEVATED_PRIVILEGES in candidate.metadata.capabilities - ) + elevated_collectors: list[str] = [] + + for candidate in selected.values(): + metadata = candidate.metadata + if metadata is None: + continue + + if Capability.ELEVATED_PRIVILEGES in metadata.capabilities: + elevated_collectors.append(metadata.id) + + elevated_collectors.sort() if elevated_collectors and inspect_environment().is_administrator is not True: raise PlanError( "selected collectors require an administrator account: " diff --git a/tests/test_configuration_documentation.py b/tests/test_configuration_documentation.py index f52767b9..b2028762 100644 --- a/tests/test_configuration_documentation.py +++ b/tests/test_configuration_documentation.py @@ -40,6 +40,9 @@ def test_every_engine_and_collector_setting_is_documented(self) -> None: def test_complete_example_loads_through_the_real_parser(self) -> None: match = re.search(r"## Complete example.*?```json\n(.*?)\n```", self.guide, re.DOTALL) self.assertIsNotNone(match) + self.assertIsNotNone(match) + assert match is not None + payload = json.loads(match.group(1)) with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/tests/test_flow_matrix.py b/tests/test_flow_matrix.py index cbe630b9..6ebababb 100644 --- a/tests/test_flow_matrix.py +++ b/tests/test_flow_matrix.py @@ -13,11 +13,12 @@ def test_every_required_flow_has_executable_regression_evidence(self) -> None: test_names: set[str] = set() for path in (project_root / "tests").glob("test_*.py"): tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) - test_names.update( - node.name for node in ast.walk(tree) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) - and node.name.startswith("test_") - ) + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + + if node.name.startswith("test_"): + test_names.add(node.name) required = { "default": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", "threaded": "test_explicit_execution_modes_control_isolated_worker_overlap", diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index da80e4d5..ceb6e4fb 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -66,10 +66,17 @@ def test_text_output_matches_golden_file(self) -> None: expected = (_GOLDEN / "system_details.txt").read_text(encoding="utf-8") with tempfile.TemporaryDirectory() as temporary: writer = _CaptureWriter() - with patch.object(system_details.subprocess, "run", - return_value=CompletedProcess([], 0, expected, "")): + with patch.object( + system_details.subprocess, + system_details.subprocess.run.__name__, + return_value=CompletedProcess([], 0, expected, ""), + ): system_details.SystemDetailsCollector().collect( - _context(Path(temporary), "core.system.system_details", writer) + _context( + Path(temporary), + "core.system.system_details", + writer, + ) ) self.assertEqual(expected.encode(), writer.contents) @@ -77,10 +84,17 @@ def test_csv_output_matches_golden_file(self) -> None: expected = (_GOLDEN / "running_processes.csv").read_text(encoding="utf-8") with tempfile.TemporaryDirectory() as temporary: writer = _CaptureWriter() - with patch.object(running_processes.subprocess, "run", - return_value=CompletedProcess([], 0, expected, "")): + with patch.object( + running_processes.subprocess, + running_processes.subprocess.run.__name__, + return_value=CompletedProcess([], 0, expected, ""), + ): running_processes.RunningProcessesCollector().collect( - _context(Path(temporary), "core.process.running_processes", writer) + _context( + Path(temporary), + "core.process.running_processes", + writer, + ) ) self.assertEqual(expected.encode(), writer.contents) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 29e3204f..dd311c24 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import subprocess import sys import tempfile @@ -11,12 +12,13 @@ from unittest.mock import Mock, patch from core.packet import packet_capture +from logicytics import environment from logicytics.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.environment import inspect_environment from logicytics.platform_adapters import ( FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, - which, windows_api_adapter, + which, windows_api_adapter, registry_adapter, ) @@ -99,10 +101,19 @@ def test_process_adapter_starts_shell_free_processes_and_reads_host_memory(self) ProcessAdapter().popen(["tool", Path("argument")], cwd=Path("C:/work"), shell=False), ) self.assertEqual(("tool", "argument"), popen.call_args.args[0]) - with patch("logicytics.platform_adapters.os.name", "nt"), patch.object( - windows_api_adapter, "process_working_set", return_value=4096 + with patch.object( + os, + os.name.__name__, + "nt", + ), patch.object( + windows_api_adapter, + windows_api_adapter.process_working_set.__name__, + return_value=4096, ) as memory: - self.assertEqual(4096, ProcessAdapter().memory_bytes(42)) + self.assertEqual( + 4096, + ProcessAdapter().memory_bytes(42), + ) memory.assert_called_once_with(42) def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: @@ -133,8 +144,15 @@ def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: "timeout_seconds": 1, "retry_window_seconds": 0}, cancellation_file=workspace / ".cancelled", ) - with patch.object(packet_capture.socket, "socket", return_value=capture), \ - patch.object(packet_capture.select, "select", return_value=([capture], [], [])): + with patch.object( + packet_capture.socket, + packet_capture.socket.socket.__name__, + return_value=capture, + ), patch.object( + packet_capture.select, + packet_capture.select.select.__name__, + return_value=([capture], [], []), + ): result = packet_capture.PacketCaptureCollector().collect(context) rows = (workspace / "packet_capture.csv").read_text(encoding="utf-8").splitlines() self.assertIs(CollectorStatus.SUCCEEDED, result.status) @@ -237,11 +255,27 @@ def test_environment_inspection_uses_only_mockable_platform_adapters(self) -> No key.__enter__ = Mock(return_value="uac-key") key.__exit__ = Mock(return_value=False) completed = subprocess.CompletedProcess((), 0, "RemoteSigned\n", "") - with patch("logicytics.environment.windows_api_adapter.is_administrator", return_value=True), \ - patch("logicytics.environment.registry_adapter.OpenKey", return_value=key), \ - patch("logicytics.environment.registry_adapter.QueryValueEx", return_value=(1, 4)), \ - patch("logicytics.environment.which", return_value="C:/Windows/PowerShell.exe"), \ - patch("logicytics.environment.process_adapter.run", return_value=completed) as run: + with patch.object( + windows_api_adapter, + windows_api_adapter.is_administrator.__name__, + return_value=True, + ), patch.object( + registry_adapter, + registry_adapter.OpenKey.__name__, + return_value=key, + ), patch.object( + registry_adapter, + registry_adapter.QueryValueEx.__name__, + return_value=(1, 4), + ), patch.object( + environment, + environment.which.__name__, + return_value="C:/Windows/PowerShell.exe", + ), patch.object( + environment.process_adapter, + environment.process_adapter.run.__name__, + return_value=completed, + ) as run: report = inspect_environment() self.assertTrue(report.is_administrator) self.assertTrue(report.uac_enabled) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 518c47f0..ccc2acf9 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -325,9 +325,15 @@ def test_invalid_unselected_plugin_does_not_block_core_plan(self) -> None: RunRequest(), ) + planned_ids: list[str] = [] + + for candidate in plan.collectors: + assert candidate.metadata is not None + planned_ids.append(candidate.metadata.id) + self.assertEqual( ["core.system.system_info"], - [candidate.metadata.id for candidate in plan.collectors], + planned_ids, ) def test_invalid_selected_folder_or_enabled_plugin_blocks_planning(self) -> None: diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index d7ed64b3..76d8d083 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -62,7 +62,7 @@ def test_access_denied_is_skipped_not_failed(self) -> None: ) with patch.object( module.subprocess, - "run", + module.subprocess.run.__name__, return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), ): result = collector_type().collect(context) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 79976ca8..756b0037 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -37,36 +37,102 @@ def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: core_directory = root / "core" / "system" core_directory.mkdir(parents=True) (root / "plugins").mkdir() + dependency_id = "core.system.a_dependency" + (core_directory / "a_dependency.py").write_text( - delayed_collector_source("a_dependency", 0.0, fail=True), + delayed_collector_source( + "a_dependency", + 0.0, + fail=True, + ), encoding="utf-8", ) + (core_directory / "b_dependent.py").write_text( - delayed_collector_source("b_dependent", 0.0, dependencies=(dependency_id,)), + delayed_collector_source( + "b_dependent", + 0.0, + dependencies=(dependency_id,), + ), encoding="utf-8", ) + report = preflight(root) self.assertEqual((), report.invalid) - plan = build_plan(report, RunRequest(max_workers=2, acknowledge_authorization=True)) - outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("failed", records[dependency_id].status) + plan = build_plan( + report, + RunRequest( + max_workers=2, + acknowledge_authorization=True, + ), + ) + + outcome = RunSupervisor( + root, + default_config(root), + ).run(plan) + + records = { + record.id: record + for record in outcome.manifest.collectors + } + + self.assertEqual( + "failed", + records[dependency_id].status, + ) + dependent = records["core.system.b_dependent"] - self.assertEqual("skipped", dependent.status) + + self.assertEqual( + "skipped", + dependent.status, + ) self.assertIsNone(dependent.started_at) - self.assertTrue(any(dependency_id in error for error in dependent.errors)) - self.assertEqual(["core.system.b_dependent"], outcome.manifest.skipped_collectors) + self.assertTrue( + any( + dependency_id in error + for error in dependent.errors + ) + ) + self.assertEqual( - {dependency_id, "core.system.b_dependent"}, - {error["collector_id"] for error in outcome.manifest.errors}, + ["core.system.b_dependent"], + outcome.manifest.skipped_collectors, + ) + + self.assertEqual( + { + dependency_id, + "core.system.b_dependent", + }, + { + error["collector_id"] + for error in outcome.manifest.errors + }, + ) + + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: + packaged = json.loads( + archive.read("metadata/manifest.json") + ) + summary = archive.read( + "reports/summary.txt" + ).decode("utf-8") + + self.assertEqual( + ["core.system.b_dependent"], + packaged["skipped_collectors"], + ) + self.assertIn( + "Skipped collectors: 1", + summary, ) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged = json.loads(archive.read("metadata/manifest.json")) - summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertEqual(["core.system.b_dependent"], packaged["skipped_collectors"]) - self.assertIn("Skipped collectors: 1", summary) def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> None: """An isolated collector crash must produce a durable failed run outcome.""" @@ -93,7 +159,11 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N self.assertEqual("failed", record.status) self.assertTrue(any("RuntimeError" in error for error in record.errors)) self.assertIsNotNone(outcome.manifest.package) - package_path = Path(outcome.manifest.package["path"]) + package = outcome.manifest.package + self.assertIsNotNone(package) + assert package is not None + + package_path = Path(package["path"]) self.assertTrue(package_path.is_file()) with zipfile.ZipFile(package_path) as archive: summary = archive.read("reports/summary.txt").decode("utf-8") @@ -152,8 +222,13 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N self.assertNotEqual(record.worker_pid, record.retry_history[0]["worker_pid"]) self.assertEqual("failed", record.retry_history[0]["termination_reason"]) self.assertEqual("completed", record.termination_reason) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: + packaged_record = json.loads( + archive.read("metadata/manifest.json") + )["collectors"][0] summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(2, packaged_record["attempt_count"]) self.assertEqual(1, len(packaged_record["retry_history"])) @@ -230,9 +305,17 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non ) self.assertFalse(failure["retry_safe"]) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: - self.assertIn("evidence/derived/core_system_system_info/system.txt", archive.namelist()) - packaged = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: + self.assertIn( + "evidence/derived/core_system_system_info/system.txt", + archive.namelist(), + ) + packaged = json.loads( + archive.read("metadata/manifest.json") + )["collectors"][0] summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(record.failure, packaged["failure"]) self.assertIn("Failed operation: collect", summary) @@ -281,13 +364,21 @@ def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolat self.assertEqual("failed", failed.termination_reason) self.assertEqual("completed", succeeded.termination_reason) self.assertNotEqual(failed.worker_pid, succeeded.worker_pid) - with zipfile.ZipFile(Path(outcome.manifest.package["path"])) as archive: + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: packaged = { item["id"]: item - for item in json.loads(archive.read("metadata/manifest.json"))["collectors"] + for item in json.loads( + archive.read("metadata/manifest.json") + )["collectors"] } summary = archive.read("reports/summary.txt").decode("utf-8") - self.assertIn("evidence/derived/core_system_a_failed/system.txt", archive.namelist()) + self.assertIn( + "evidence/derived/core_system_a_failed/system.txt", + archive.namelist(), + ) self.assertEqual("failed", packaged["core.system.a_failed"]["status"]) self.assertEqual("process", packaged["core.system.a_failed"]["isolation_mode"]) self.assertEqual(failed.worker_pid, packaged["core.system.a_failed"]["worker_pid"]) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 6ce64831..e93c6326 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -37,20 +37,37 @@ def test_parallel_completion_preserves_deterministic_manifest_order(self) -> Non delayed_collector_source("z_fast", 0.0), encoding="utf-8", ) + report = preflight(root) self.assertEqual((), report.invalid) + plan = build_plan( report, RunRequest(max_workers=2, acknowledge_authorization=True), ) - planned_ids = [candidate.metadata.id for candidate in plan.collectors] + + planned_ids: list[str] = [] + for candidate in plan.collectors: + assert candidate.metadata is not None + planned_ids.append(candidate.metadata.id) + outcome = RunSupervisor(root, default_config(root)).run(plan) - records = {record.id: record for record in outcome.manifest.collectors} + records = { + record.id: record + for record in outcome.manifest.collectors + } + + self.assertEqual( + planned_ids, + [record.id for record in outcome.manifest.collectors], + ) - self.assertEqual(planned_ids, [record.id for record in outcome.manifest.collectors]) fast_finished = records["core.system.z_fast"].finished_at slow_finished = records["core.system.a_slow"].finished_at - assert fast_finished is not None and slow_finished is not None + + assert fast_finished is not None + assert slow_finished is not None + self.assertLess(fast_finished, slow_finished) def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: diff --git a/tests/test_wmic_inventory_collector.py b/tests/test_wmic_inventory_collector.py index 85b04f78..54fdd842 100644 --- a/tests/test_wmic_inventory_collector.py +++ b/tests/test_wmic_inventory_collector.py @@ -34,12 +34,15 @@ def test_wmic_inventory_runs_the_bounded_read_only_query(self) -> None: cancellation_file=workspace / ".cancelled", ) response = "Manufacturer=Example Corp\r\nModel=Example Model\r\n" - with patch.object(wmic_inventory, "which", return_value="C:/Windows/System32/wbem/WMIC.exe"), \ - patch.object( - wmic_inventory.subprocess, - "run", - return_value=subprocess.CompletedProcess((), 0, response, ""), - ) as run: + with patch.object( + wmic_inventory, + wmic_inventory.which.__name__, + return_value="C:/Windows/System32/wbem/WMIC.exe", + ), patch.object( + wmic_inventory.subprocess, + wmic_inventory.subprocess.run.__name__, + return_value=subprocess.CompletedProcess((), 0, response, ""), + ) as run: collector = wmic_inventory.WmicInventoryCollector() self.assertTrue(collector.validate(context).valid) result = collector.collect(context) @@ -68,7 +71,11 @@ def test_absent_wmic_is_an_explicit_skip(self) -> None: settings={}, cancellation_file=root / ".cancelled", ) - with patch.object(wmic_inventory, "which", return_value=None): + with patch.object( + wmic_inventory, + wmic_inventory.which.__name__, + return_value=None, + ): collector = wmic_inventory.WmicInventoryCollector() validation = collector.validate(context) result = collector.collect(context) From 0c5bbb2cb4e96ec400cc2caaa0eae80912333de2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 03:31:52 +0300 Subject: [PATCH 239/316] refactor: centralize ctypes bindings and remove duplicates Signed-off-by: DefinetlyNotAI --- core/memory/memory_snapshot.py | 33 +--- core/process/memory_map.py | 192 +++++++++++++------- core/storage/logical_drives.py | 36 ++-- logicytics/ctypes_collector.py | 301 +++++++++++++++++++++++++++++++- logicytics/platform_adapters.py | 13 +- 5 files changed, 457 insertions(+), 118 deletions(-) diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index 3a145afe..7f95f4d9 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -2,39 +2,12 @@ from __future__ import annotations -import ctypes import json from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.platform_adapters import windows_api_adapter - - -class _MemoryStatus(ctypes.Structure): - """Mirror the Windows MEMORYSTATUSEX structure used by GlobalMemoryStatusEx.""" - - _fields_ = [ - ("dwLength", ctypes.c_ulong), - ("dwMemoryLoad", ctypes.c_ulong), - ("ullTotalPhys", ctypes.c_ulonglong), - ("ullAvailPhys", ctypes.c_ulonglong), - ("ullTotalPageFile", ctypes.c_ulonglong), - ("ullAvailPageFile", ctypes.c_ulonglong), - ("ullTotalVirtual", ctypes.c_ulonglong), - ("ullAvailVirtual", ctypes.c_ulonglong), - ("ullAvailExtendedVirtual", ctypes.c_ulonglong), - ] - - -def _read_memory_status() -> _MemoryStatus: - """Read Windows aggregate memory counters or raise the platform error.""" - status = _MemoryStatus() - status.dwLength = ctypes.sizeof(_MemoryStatus) - kernel32 = windows_api_adapter.load_library("kernel32") - if not kernel32.GlobalMemoryStatusEx(ctypes.byref(status)): - raise ctypes.WinError() - return status +from logicytics.ctypes_collector import global_memory_status class MemorySnapshotCollector(CoreCollector): @@ -62,7 +35,7 @@ def validate(self, context: CollectorContext) -> ValidationResult: if context.is_cancelled: return ValidationResult(False, reasons=("run cancellation was requested",)) try: - _read_memory_status() + global_memory_status() except OSError as error: return ValidationResult(False, reasons=(f"memory API is unavailable: {error}",)) return ValidationResult(True) @@ -73,7 +46,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory collection") context.report_progress("memory_snapshot_started") try: - status = _read_memory_status() + status = global_memory_status() except OSError as error: return CollectorResult(CollectorStatus.FAILED, "could not read Windows memory status", errors=(str(error),)) report = { diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 0001e757..8d39613c 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -2,21 +2,14 @@ from __future__ import annotations -import ctypes import json -from ctypes import wintypes from pathlib import Path from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.ctypes_collector import ProcessMemoryCounters -from logicytics.platform_adapters import filesystem_adapter, windows_api_adapter - - -class _MemoryBasicInformation(ctypes.Structure): - _fields_ = [("BaseAddress", wintypes.LPVOID), ("AllocationBase", wintypes.LPVOID), - ("AllocationProtect", wintypes.DWORD), ("PartitionId", wintypes.WORD), ("RegionSize", ctypes.c_size_t), - ("State", wintypes.DWORD), ("Protect", wintypes.DWORD), ("Type", wintypes.DWORD)] +from logicytics.ctypes_collector import ProcessMemoryCounters, MemoryBasicInformation, get_process_memory_info, \ + virtual_query, get_mapped_file_name, get_current_process, pointer_value +from logicytics.platform_adapters import filesystem_adapter def _permissions(protection: int) -> str: @@ -91,7 +84,11 @@ def setting_int(name: str, default: int) -> int: def collect(self, context: CollectorContext) -> CollectorResult: """Query readable local memory regions and write metadata-only JSON evidence.""" if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory-map collection") + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before memory-map collection", + ) + maximum = context.setting_int("max_regions", 5_000) output_limit = context.setting_int( "output_limit_bytes", @@ -101,93 +98,170 @@ def collect(self, context: CollectorContext) -> CollectorResult: "disk_safety_margin_bytes", 100 * 1024 * 1024, ) + configured_directory = Path( context.setting_str("dump_directory", "memory_maps") ) - output_directory = (context.workspace / configured_directory).resolve() + output_directory = ( + context.workspace / configured_directory + ).resolve() + try: output_directory.relative_to(context.workspace.resolve()) except ValueError: - return CollectorResult(CollectorStatus.FAILED, - "memory-map dump_directory must stay inside the collector workspace") - try: - kernel32 = windows_api_adapter.load_library("kernel32") - psapi = windows_api_adapter.load_library("psapi") - except OSError as error: return CollectorResult( CollectorStatus.FAILED, - "could not load Windows memory-map APIs", - errors=(str(error),), + "memory-map dump_directory must stay inside the collector workspace", ) - process = kernel32.GetCurrentProcess() + + process = get_current_process() + counters = ProcessMemoryCounters() - psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), ctypes.sizeof(counters)) - memory = _MemoryBasicInformation() + if not get_process_memory_info(process, counters): + return CollectorResult( + CollectorStatus.FAILED, + "could not query process memory counters", + ) + + memory = MemoryBasicInformation() address = 0 regions: list[dict[str, object]] = [] - context.report_progress("memory_map_started", max_regions=maximum) + + context.report_progress( + "memory_map_started", + max_regions=maximum, + ) + while len(regions) < maximum: if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during memory-map collection") - queried = kernel32.VirtualQuery(ctypes.c_void_p(address), ctypes.byref(memory), ctypes.sizeof(memory)) - if not queried or memory.RegionSize == 0: + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled during memory-map collection", + ) + + queried = virtual_query(address, memory) + region_size = int(memory.RegionSize) + + if not queried or region_size == 0: break - base = ctypes.cast(memory.BaseAddress, ctypes.c_void_p).value or address - readable = memory.State == 0x1000 and not memory.Protect & 0x101 - if readable: - base_address = int(base) - mapped = ctypes.create_unicode_buffer(32_768) - mapped_length = psapi.GetMappedFileNameW( + base_address = pointer_value(memory.BaseAddress) + if base_address is None: + base_address = address + + readable = ( + memory.State == 0x1000 + and not memory.Protect & 0x101 + ) + + if readable: + mapped_path = get_mapped_file_name( process, - ctypes.c_void_p(base_address), - mapped, - len(mapped), + base_address, ) regions.append( { "index": len(regions), "address": f"0x{base_address:016X}", - "size_bytes": memory.RegionSize, - "rss_bytes": counters.WorkingSetSize, - "permissions": _permissions(memory.Protect), - "mapped_path": mapped.value if mapped_length else None, - "state": memory.State, - "type": memory.Type, + "size_bytes": region_size, + "rss_bytes": int(counters.WorkingSetSize), + "permissions": _permissions(int(memory.Protect)), + "mapped_path": mapped_path, + "state": int(memory.State), + "type": int(memory.Type), } ) - next_address = base + memory.RegionSize + next_address = base_address + region_size + if next_address <= address: break + address = next_address + truncated = False + while True: if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during memory-map serialization") - serialized = json.dumps({"region_count": len(regions), "truncated": truncated, "regions": regions}, - indent=2) + "\n" - if len(serialized.encode("utf-8")) <= output_limit or not regions: + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled during memory-map serialization", + ) + + serialized = json.dumps( + { + "region_count": len(regions), + "truncated": truncated, + "regions": regions, + }, + indent=2, + ) + "\n" + + serialized_size = len(serialized.encode("utf-8")) + + if serialized_size <= output_limit or not regions: break + regions.pop() truncated = True - if filesystem_adapter.disk_usage(context.workspace).free < len(serialized.encode("utf-8")) + safety_margin: - return CollectorResult(CollectorStatus.SKIPPED, - "insufficient free disk space after configured memory-map safety margin") + + if ( + filesystem_adapter.disk_usage(context.workspace).free + < serialized_size + safety_margin + ): + return CollectorResult( + CollectorStatus.SKIPPED, + "insufficient free disk space after configured memory-map safety margin", + ) + if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, "cancelled before memory-map publication") - output_directory.mkdir(parents=True, exist_ok=True) + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled before memory-map publication", + ) + + output_directory.mkdir( + parents=True, + exist_ok=True, + ) + output = output_directory / "memory_map.json" - output.write_text(serialized, encoding="utf-8") + output.write_text( + serialized, + encoding="utf-8", + ) + if context.is_cancelled: output.unlink(missing_ok=True) - return CollectorResult(CollectorStatus.CANCELLED, "cancelled during memory-map publication") - artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("memory_map_finished", region_count=len(regions), truncated=str(truncated).lower(), - bytes_written=artifact.size_bytes) - summary = "process memory map collected" if not truncated else "process memory map collected with configured output truncation" - return CollectorResult.succeeded(summary, (artifact,)) + + return CollectorResult( + CollectorStatus.CANCELLED, + "cancelled during memory-map publication", + ) + + artifact = context.artifacts.register_file( + output, + media_type="application/json", + ) + + context.report_progress( + "memory_map_finished", + region_count=len(regions), + truncated=str(truncated).lower(), + bytes_written=artifact.size_bytes, + ) + + summary = ( + "process memory map collected" + if not truncated + else "process memory map collected with configured output truncation" + ) + + return CollectorResult.succeeded( + summary, + (artifact,), + ) def cleanup(self, context: CollectorContext) -> None: """Release no resources because the collector only queries its own process.""" diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index 385ee44b..5988d72e 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -2,13 +2,12 @@ from __future__ import annotations -import ctypes import json from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.platform_adapters import windows_api_adapter +from logicytics.ctypes_collector import get_logical_drives, ularge_integer, get_disk_free_space, get_drive_type _DRIVE_TYPES = { 0: "unknown", @@ -23,35 +22,38 @@ def _logical_drives() -> list[dict[str, int | str]]: """Return Windows logical drive metadata without walking any filesystem contents.""" - kernel32 = windows_api_adapter.load_library("kernel32") - mask = kernel32.GetLogicalDrives() - if mask == 0: - raise ctypes.WinError() + mask = get_logical_drives() drives: list[dict[str, int | str]] = [] + for offset in range(26): if not mask & (1 << offset): continue + root = f"{chr(ord('A') + offset)}:\\" - available = ctypes.c_ulonglong() - total = ctypes.c_ulonglong() - free = ctypes.c_ulonglong() - if not kernel32.GetDiskFreeSpaceExW( + available = ularge_integer() + total = ularge_integer() + free = ularge_integer() + + if not get_disk_free_space( root, - ctypes.byref(available), - ctypes.byref(total), - ctypes.byref(free), + available, + total, + free, ): continue - drive_type_code = kernel32.GetDriveTypeW(root) + + drive_type_code = get_drive_type(root) + drives.append( { "root": root, "type": _DRIVE_TYPES.get(drive_type_code, "unknown"), - "total_bytes": total.value, - "free_bytes": free.value, - "available_bytes": available.value, + "total_bytes": int(total.value), + "free_bytes": int(free.value), + "available_bytes": int(available.value), } ) + return drives diff --git a/logicytics/ctypes_collector.py b/logicytics/ctypes_collector.py index c9d5b1bd..f6e240f7 100644 --- a/logicytics/ctypes_collector.py +++ b/logicytics/ctypes_collector.py @@ -10,20 +10,30 @@ if sys.platform != "win32": raise RuntimeError("ctypes_collector is only supported on Windows") +# ctypes aliases + DWORD: TypeAlias = wintypes.DWORD HANDLE: TypeAlias = wintypes.HANDLE BOOL: TypeAlias = wintypes.BOOL ULONG: TypeAlias = ctypes.c_ulong SIZE_T: TypeAlias = ctypes.c_size_t ULARGE_INTEGER: TypeAlias = ctypes.c_ulonglong +FILETIME: TypeAlias = wintypes.FILETIME +LPVOID: TypeAlias = wintypes.LPVOID +WORD: TypeAlias = wintypes.WORD LPDWORD = ctypes.POINTER(DWORD) LPULARGE_INTEGER = ctypes.POINTER(ULARGE_INTEGER) +# Windows constants + PROCESS_QUERY_LIMITED_INFORMATION: Final = 0x1000 STILL_ACTIVE: Final = 259 +# Windows structures + + class ProcessMemoryCounters(ctypes.Structure): """Windows PROCESS_MEMORY_COUNTERS structure.""" @@ -41,7 +51,44 @@ class ProcessMemoryCounters(ctypes.Structure): ] +class MemoryBasicInformation(ctypes.Structure): + """Windows MEMORY_BASIC_INFORMATION structure.""" + + _fields_ = [ + ("BaseAddress", LPVOID), + ("AllocationBase", LPVOID), + ("AllocationProtect", DWORD), + ("PartitionId", WORD), + ("RegionSize", SIZE_T), + ("State", DWORD), + ("Protect", DWORD), + ("Type", DWORD), + ] + + +class MemoryStatus(ctypes.Structure): + """Windows MEMORYSTATUSEX structure.""" + + _fields_ = [ + ("dwLength", ULONG), + ("dwMemoryLoad", ULONG), + ("ullTotalPhys", ULARGE_INTEGER), + ("ullAvailPhys", ULARGE_INTEGER), + ("ullTotalPageFile", ULARGE_INTEGER), + ("ullAvailPageFile", ULARGE_INTEGER), + ("ullTotalVirtual", ULARGE_INTEGER), + ("ullAvailVirtual", ULARGE_INTEGER), + ("ullAvailExtendedVirtual", ULARGE_INTEGER), + ] + + +# DLL handles + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) +advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) +psapi = ctypes.WinDLL("psapi", use_last_error=True) + +# kernel32 bindings _OpenProcess = kernel32.OpenProcess _OpenProcess.argtypes = [ @@ -64,6 +111,20 @@ class ProcessMemoryCounters(ctypes.Structure): ] _CloseHandle.restype = BOOL +_GetCurrentProcess = kernel32.GetCurrentProcess +_GetCurrentProcess.argtypes = [] +_GetCurrentProcess.restype = HANDLE + +_GetLogicalDrives = kernel32.GetLogicalDrives +_GetLogicalDrives.argtypes = [] +_GetLogicalDrives.restype = DWORD + +_GetDriveTypeW = kernel32.GetDriveTypeW +_GetDriveTypeW.argtypes = [ + wintypes.LPCWSTR, +] +_GetDriveTypeW.restype = wintypes.UINT + _GetVolumeInformationW = kernel32.GetVolumeInformationW _GetVolumeInformationW.argtypes = [ wintypes.LPCWSTR, @@ -86,6 +147,61 @@ class ProcessMemoryCounters(ctypes.Structure): ] _GetDiskFreeSpaceExW.restype = BOOL +_GlobalMemoryStatusEx = kernel32.GlobalMemoryStatusEx +_GlobalMemoryStatusEx.argtypes = [ + ctypes.POINTER(MemoryStatus), +] +_GlobalMemoryStatusEx.restype = BOOL + +_VirtualQuery = kernel32.VirtualQuery +_VirtualQuery.argtypes = [ + LPVOID, + ctypes.POINTER(MemoryBasicInformation), + SIZE_T, +] +_VirtualQuery.restype = SIZE_T + +# advapi32 bindings + +_RegQueryInfoKeyW = advapi32.RegQueryInfoKeyW +_RegQueryInfoKeyW.argtypes = [ + wintypes.HKEY, + wintypes.LPWSTR, + LPDWORD, + LPDWORD, + LPDWORD, + LPDWORD, + LPDWORD, + LPDWORD, + LPDWORD, + LPDWORD, + LPDWORD, + ctypes.POINTER(FILETIME), +] +_RegQueryInfoKeyW.restype = wintypes.LONG + +# psapi bindings + +_GetProcessMemoryInfo = psapi.GetProcessMemoryInfo +_GetProcessMemoryInfo.argtypes = [ + HANDLE, + ctypes.POINTER(ProcessMemoryCounters), + DWORD, +] +_GetProcessMemoryInfo.restype = BOOL + +_GetMappedFileNameW = psapi.GetMappedFileNameW +_GetMappedFileNameW.argtypes = [ + HANDLE, + LPVOID, + wintypes.LPWSTR, + DWORD, +] +_GetMappedFileNameW.restype = DWORD + + +# Process wrappers + def open_process( process_id: int, @@ -107,6 +223,12 @@ def open_process( return handle +def get_current_process() -> HANDLE: + """Return the pseudo-handle for the current process.""" + + return _GetCurrentProcess() + + def get_exit_code_process(handle: HANDLE) -> int | None: """Return a process exit code, or None if querying it failed.""" @@ -127,6 +249,92 @@ def close_handle(handle: HANDLE) -> bool: return bool(_CloseHandle(handle)) +def get_process_memory_info( + process: HANDLE, + counters: ProcessMemoryCounters, +) -> bool: + """Populate memory counters for a process.""" + + counters.cb = ctypes.sizeof(counters) + + return bool( + _GetProcessMemoryInfo( + process, + ctypes.byref(counters), + ctypes.sizeof(counters), + ) + ) + + +# Virtual-memory wrappers + + +def virtual_query( + address: int, + memory: MemoryBasicInformation, +) -> int: + """Query the virtual-memory region containing an address.""" + + return int( + _VirtualQuery( + ctypes.c_void_p(address), + ctypes.byref(memory), + ctypes.sizeof(memory), + ) + ) + + +def get_mapped_file_name( + process: HANDLE, + address: int, + buffer_size: int = 32_768, +) -> str | None: + """Return the mapped filename associated with a process address.""" + + buffer = ctypes.create_unicode_buffer(buffer_size) + + length = _GetMappedFileNameW( + process, + ctypes.c_void_p(address), + buffer, + buffer_size, + ) + + if not length: + return None + + return buffer.value[: int(length)] + + +def pointer_value(pointer: LPVOID) -> int | None: + """Return the integer value represented by a Windows pointer.""" + + return ctypes.cast( + pointer, + ctypes.c_void_p, + ).value + + +# Drive and volume wrappers + + +def get_logical_drives() -> int: + """Return the bitmask identifying available Windows logical drives.""" + + mask = _GetLogicalDrives() + + if mask == 0: + raise ctypes.WinError(ctypes.get_last_error()) + + return int(mask) + + +def get_drive_type(root: str) -> int: + """Return the Windows drive type code for a filesystem root.""" + + return int(_GetDriveTypeW(root)) + + def get_volume_information( root: str, label: ctypes.Array[ctypes.c_wchar], @@ -169,6 +377,51 @@ def get_disk_free_space( ) +# Memory wrappers + + +def global_memory_status() -> MemoryStatus: + """Return current Windows physical and virtual memory statistics.""" + + status = MemoryStatus() + status.dwLength = ctypes.sizeof(status) + + if not _GlobalMemoryStatusEx(ctypes.byref(status)): + raise ctypes.WinError(ctypes.get_last_error()) + + return status + + +# Registry wrappers + + +def query_registry_key_info( + key_handle: int, + timestamp: FILETIME, +) -> int: + """Query registry key metadata and populate its last-write timestamp.""" + + return int( + _RegQueryInfoKeyW( + wintypes.HKEY(key_handle), + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + ctypes.byref(timestamp), + ) + ) + + +# ctypes factories + + def create_unicode_buffer(size: int) -> ctypes.Array[ctypes.c_wchar]: """Create a mutable Windows Unicode buffer.""" @@ -193,25 +446,61 @@ def ularge_integer(value: int = 0) -> ULARGE_INTEGER: return ULARGE_INTEGER(value) +def filetime() -> FILETIME: + """Create an empty Windows FILETIME structure.""" + + return FILETIME() + + __all__ = [ + # Types "BOOL", "DWORD", + "FILETIME", "HANDLE", "LPDWORD", "LPULARGE_INTEGER", - "PROCESS_QUERY_LIMITED_INFORMATION", + "LPVOID", + "MemoryBasicInformation", + "MemoryStatus", "ProcessMemoryCounters", "SIZE_T", - "STILL_ACTIVE", "ULARGE_INTEGER", "ULONG", + "WORD", + + # Constants + "PROCESS_QUERY_LIMITED_INFORMATION", + "STILL_ACTIVE", + + # Process API "close_handle", - "create_unicode_buffer", - "dword", - "get_disk_free_space", + "get_current_process", "get_exit_code_process", - "get_volume_information", + "get_process_memory_info", "open_process", + + # Virtual memory API + "get_mapped_file_name", + "pointer_value", + "virtual_query", + + # Drive and volume API + "get_disk_free_space", + "get_drive_type", + "get_logical_drives", + "get_volume_information", + + # Memory API + "global_memory_status", + + # Registry API + "query_registry_key_info", + + # ctypes factories + "create_unicode_buffer", + "dword", + "filetime", "ularge_integer", "ulong", ] diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 487b35d8..b2b575aa 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -15,6 +15,8 @@ from pathlib import Path from typing import Any +from logicytics.ctypes_collector import query_registry_key_info, filetime + try: import winreg as _winreg except ImportError: # pragma: no cover - exercised by non-Windows package imports @@ -221,14 +223,13 @@ def last_write_time(key: Any) -> str | None: """Return one key's Win32 last-write timestamp in UTC when available.""" if _winreg is None: return None - import ctypes - from ctypes import wintypes - timestamp = wintypes.FILETIME() - result = ctypes.windll.advapi32.RegQueryInfoKeyW( - wintypes.HKEY(key.handle), None, None, None, None, None, None, - None, None, None, None, ctypes.byref(timestamp), + timestamp = filetime() + result = query_registry_key_info( + key.handle, + timestamp, ) + if result != 0: return None ticks = (timestamp.dwHighDateTime << 32) | timestamp.dwLowDateTime From 87262859fc42761bbbaa44056289930b44b69c0a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Sat, 5 Sep 2026 03:44:45 +0300 Subject: [PATCH 240/316] refactor: centralize platform APIs and improve typing - move Windows ctypes and registry access behind adapters - update tests for new platform abstraction boundaries - improve optional metadata and mapping type narrowing - fix PathLike, TypedDict, enum, and artifact writer typing - clean up pylint warnings, duplicate-code checks, and regex groups - improve deterministic filesystem handling and collector validation - removed redundant character escape '\]' in RegExp Signed-off-by: DefinetlyNotAI --- core/integration/legacy_code_outputs.py | 4 ++-- logicytics/maintenance.py | 6 +++--- tests/test_collector_cancellation.py | 2 +- tests/test_shipped_collectors.py | 4 ++++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py index 5614cb19..c38de1e3 100644 --- a/core/integration/legacy_code_outputs.py +++ b/core/integration/legacy_code_outputs.py @@ -129,8 +129,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: with ( - source.open("rb") as source_stream, - temporary.open("xb") as destination_stream, + open(source, "rb") as source_stream, + open(temporary, "xb") as destination_stream, ): while block := source_stream.read(1024 * 1024): if context.is_cancelled: diff --git a/logicytics/maintenance.py b/logicytics/maintenance.py index 465e66b6..b6bb4d6d 100644 --- a/logicytics/maintenance.py +++ b/logicytics/maintenance.py @@ -18,7 +18,7 @@ _SHA256 = re.compile(r"^[0-9a-f]{64}$") _VERSION = re.compile( - r"^(\d+)\.(\d+)\.(\d+)(?:(?:-([0-9A-Za-z.-]+))|((?:a|b|rc|\.dev)\d+))?$" + r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+)|((?:a|b|rc|\.dev)\d+))?$" ) _MAXIMUM_MANIFEST_BYTES = 2 * 1024 * 1024 _EXCLUDED_PARTS = frozenset( @@ -226,10 +226,10 @@ def write_legacy_ini_manifest( text = payload.decode("utf-8-sig") except UnicodeDecodeError as error: raise ValueError(f"legacy config.ini is not valid UTF-8: {error}") from error - header = re.search(r"(?m)^\[System Settings\][ \t]*\r?$", text) + header = re.search(r"(?m)^\[System Settings][ \t]*\r?$", text) if header is None: raise ValueError("legacy config.ini is missing [System Settings]") - following = re.search(r"(?m)^\[[^]\r\n]+\][ \t]*\r?$", text[header.end():]) + following = re.search(r"(?m)^\[[^]\r\n]+][ \t]*\r?$", text[header.end():]) section_end = header.end() + following.start() if following is not None else len(text) replacement = text[header.start():section_end] files = ", ".join(sorted(manifest.files)) diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index 2d4280ba..b4eacf55 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -601,7 +601,7 @@ def fake_windll( return next(dlls) with patch( - "core.process.memory_map.ctypes.WinDLL", + "logicytics.ctypes_collector.ctypes.WinDLL", side_effect=fake_windll, ): memory_result = ( diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 73db1ffd..42b8126e 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -826,6 +826,10 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( with self.subTest( collector=metadata.id ): + metadata = candidate.metadata + self.assertIsNotNone(metadata) + assert metadata is not None + workspace = root / metadata.id.replace(".", "_") artifact_root = ( From 146140d50c376103e52e7ba55d1536067f412e77 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 14:04:06 +0300 Subject: [PATCH 241/316] refactor: reorganize Logicytics application package Move command, application, and ctypes infrastructure into explicit package boundaries. Add root YAML configuration, virtual-environment bootstrap tooling, aligned logging, Sysinternals opt-out configuration, and refreshed user-facing documentation. --- .gitignore | 1 + README.md | 532 ++----------------- core/memory/memory_snapshot.py | 2 +- core/process/memory_map.py | 2 +- core/storage/logical_drives.py | 2 +- core/storage/volume_details.py | 2 +- docs/CONFIGURATION.md | 208 +------- docs/MIGRATION.md | 59 +- docs/TODO.md | 7 +- docs/V4_RELEASE.md | 2 +- logicytics.yaml | 27 + logicytics/__init__.py | 16 +- logicytics/cli/__init__.py | 5 + logicytics/cli/__main__.py | 7 + logicytics/{cli.py => cli/commands.py} | 108 ++-- logicytics/cli/installer.py | 38 ++ logicytics/cli/tests.py | 39 ++ logicytics/global/__init__.py | 6 + logicytics/{ => global}/ctypes_collector.py | 0 logicytics/module/__init__.py | 1 + logicytics/{ => module}/api.py | 12 +- logicytics/{ => module}/artifacts.py | 2 +- logicytics/{ => module}/command_runner.py | 0 logicytics/{ => module}/configuration.py | 307 +++++------ logicytics/{ => module}/discovery.py | 4 +- logicytics/{ => module}/environment.py | 0 logicytics/{ => module}/errors.py | 0 logicytics/{ => module}/interaction.py | 0 logicytics/{ => module}/logging.py | 38 +- logicytics/{ => module}/maintenance.py | 41 +- logicytics/{ => module}/manifest.py | 2 +- logicytics/{ => module}/mod_runner.py | 2 +- logicytics/{ => module}/modes.py | 2 +- logicytics/{ => module}/output_contracts.py | 0 logicytics/{ => module}/output_layout.py | 0 logicytics/{ => module}/packaging.py | 6 +- logicytics/{ => module}/planner.py | 6 +- logicytics/{ => module}/redaction.py | 0 logicytics/{ => module}/runtime.py | 22 +- logicytics/module/sysinternals.py | 71 +++ logicytics/{ => module}/validation_worker.py | 0 logicytics/platform_adapters.py | 15 +- logicytics/sysinternals.py | 45 -- tests/test_artifact_writer.py | 12 +- tests/test_cli.py | 2 +- tests/test_configuration.py | 36 +- tests/test_configuration_documentation.py | 8 +- tests/test_golden_outputs.py | 2 +- tests/test_interaction.py | 10 +- tests/test_legacy_code_outputs_collector.py | 2 +- tests/test_logging.py | 14 +- tests/test_maintenance.py | 12 +- tests/test_mods.py | 12 +- tests/test_packaging.py | 26 +- tests/test_planning.py | 20 +- tests/test_platform_adapters.py | 6 +- tests/test_preflight.py | 22 +- tests/test_public_api.py | 8 +- tests/test_release_documentation.py | 4 +- tests/test_runtime.py | 8 +- tests/test_scheduler.py | 8 +- tests/test_shipped_collectors.py | 8 +- tests/test_windows_integration.py | 6 +- tests/test_wmic_inventory_collector.py | 2 +- tests/test_worker_security.py | 14 +- 65 files changed, 661 insertions(+), 1220 deletions(-) create mode 100644 logicytics.yaml create mode 100644 logicytics/cli/__init__.py create mode 100644 logicytics/cli/__main__.py rename logicytics/{cli.py => cli/commands.py} (91%) create mode 100644 logicytics/cli/installer.py create mode 100644 logicytics/cli/tests.py create mode 100644 logicytics/global/__init__.py rename logicytics/{ => global}/ctypes_collector.py (100%) create mode 100644 logicytics/module/__init__.py rename logicytics/{ => module}/api.py (98%) rename logicytics/{ => module}/artifacts.py (99%) rename logicytics/{ => module}/command_runner.py (100%) rename logicytics/{ => module}/configuration.py (70%) rename logicytics/{ => module}/discovery.py (99%) rename logicytics/{ => module}/environment.py (100%) rename logicytics/{ => module}/errors.py (100%) rename logicytics/{ => module}/interaction.py (100%) rename logicytics/{ => module}/logging.py (85%) rename logicytics/{ => module}/maintenance.py (87%) rename logicytics/{ => module}/manifest.py (99%) rename logicytics/{ => module}/mod_runner.py (96%) rename logicytics/{ => module}/modes.py (99%) rename logicytics/{ => module}/output_contracts.py (100%) rename logicytics/{ => module}/output_layout.py (100%) rename logicytics/{ => module}/packaging.py (99%) rename logicytics/{ => module}/planner.py (97%) rename logicytics/{ => module}/redaction.py (100%) rename logicytics/{ => module}/runtime.py (98%) create mode 100644 logicytics/module/sysinternals.py rename logicytics/{ => module}/validation_worker.py (100%) delete mode 100644 logicytics/sysinternals.py diff --git a/.gitignore b/.gitignore index 704e34a4..e13af176 100644 --- a/.gitignore +++ b/.gitignore @@ -320,6 +320,7 @@ $RECYCLE.BIN/ /CODE/SysInternal_Suite/.sys.ignore /ACCESS/ /output/ +/tools/ /CODE/vulnscan/tools/NN features/ /CODE/logicytics/User_History.json.gz /CODE/logicytics/User_History.json diff --git a/README.md b/README.md index 7100e1b5..aacb4827 100644 --- a/README.md +++ b/README.md @@ -1,522 +1,66 @@ -# Logicytics v4 +# Logicytics -Logicytics v4.0 is a complete recreation of the Windows system-data collection -and evidence-packaging tool around an explicit run-oriented pipeline: +

+ Reliable Windows evidence collection, organized around one verified run at a time. +

-`request -> validated plan -> isolated collectors -> registered artifacts -> manifest -> package` +
+ GitHub Issues + GitHub Tag + GitHub Commit Activity + GitHub Language Count + GitHub Branch Check Runs + GitHub Repo Size +
+
+ GitHub Repo CodeFactor Rating + Maintainability + OpenSSF Best Practices Score + OpenSSF Best Practices Badge +
-The release ships 66 independently runnable core collectors, typed profiles and -modes, opt-in plugins and MODs, isolated workers, reproducible manifests and -packages, and bounded Windows integrations. See [V4_RELEASE.md](docs/V4_RELEASE.md) -for the release scope and [FEATURE_STATUS.md](docs/FEATURE_STATUS.md) for ownership -and verification evidence. +Logicytics collects authorized Windows evidence through isolated collectors and leaves a manifest-backed run folder that can be inspected, packaged, and verified. It is built for clear outcomes: a collector can succeed, skip, partially complete, or fail without hiding what happened to the rest of the run. -## Current capabilities +> Use Logicytics only on systems and data you are authorized to inspect. -- Typed `core/` and `plugins/` collector contracts. -- Strict preflight validation before a collector can run. -- One process and private workspace per collector. -- Explicit capability approval and collection authorization. -- Structured JSONL engine and collector logs. -- Cooperative cancellation across long walks, captures, queries, memory enumeration, - and source-copy loops, with unpublished sensitive bytes removed. -- Artifact registration with workspace boundaries, output limits, SHA-256 hashes, - a run manifest, ZIP package, and package hash. -- Sixty-six shipped collectors cover all v4 specialties. The authoritative - collector catalog and exact mode membership are emitted by - `python -m logicytics --modes`; representative collectors include - `core.system.system_info` and the capability-gated - `core.process.running_processes` and `core.network.network_identity`, plus - `core.memory.memory_snapshot`, `core.storage.logical_drives`, and deep-profile - `core.hardware.windows_features`, `core.network.network_adapters`, and - `core.hardware.battery_status`, - `core.bluetooth.paired_devices`, plus `core.usb.usb_storage_inventory`. - Deep-profile inventory also includes `core.bluetooth.bluetooth_addresses`. - Deep-profile collection also includes `core.system.system_details`, - `core.system.bios_info`, - `core.system.operating_system`, - `core.system.computer_system`, - `core.system.session_snapshot`, - `core.system.system_diagnostics`, - `core.process.detailed_processes`, - `core.process.process_memory`, - `core.registry.installed_applications`, - `core.registry.startup_applications`, - `core.filesystem.system_drive_tree`, - `core.filesystem.system_drive_listing`, - `core.network.active_connections`, - `core.network.adapter_statistics`, - `core.network.bandwidth_sample`, - `core.network.network_interfaces`, - `core.network.connection_processes`, - `core.wireless.wifi_profiles`, - `core.event_log.application_events`, - `core.event_log.security_events`, - `core.system.installed_drivers`, and - `core.system.installed_updates`, - `core.system.windows_services`, - `core.system.group_policy`, `core.event_log.system_events`, and - `core.network.arp_cache`, `core.network.routing_table`, and - `core.storage.physical_disks`, and `core.storage.mounted_volumes`. - `core.storage.volume_details` adds filesystem and label metadata in the deep profile. - `core.encryption.bitlocker_status` is also available in the deep profile. - `core.encryption.bitlocker_volumes` provides the PowerShell volume view, while - `core.system.wmic_inventory` preserves an optional bounded WMIC view when that - Windows capability is installed. +## Get started -## Layout - -```text -core/ - system/ - system_info.py # shipped collectors -plugins/ # user-created collectors, validated separately -logicytics/ # engine, contracts, planning, runtime, and packaging -tests/ # core integration tests -output/ # ignored generated evidence and diagnostics - logs/ - Logicytics.log # bounded redacted application log - debug/ # debug-action diagnostics - performance/ # stable performance-report location - data/ - zip/ # created compatibility landing directory; canonical packages are run-owned - hashes/ # created compatibility landing directory; canonical hashes are run-owned - run-/ # one self-contained run output tree - artifacts/ # collector-owned registered evidence store - collectors/ # private collector workspaces and event channels - logs/ # engine and performance diagnostics - reports/ # human-readable run summary - packages/ - run--run-.zip # verified evidence package - hashes/ - artifacts.sha256 # packaged-artifact checksum catalog - run--run-.zip.sha256 # matching package SHA-256 sidecar -``` - -Each verified ZIP uses a versioned, non-overlapping layout. Registered source -material is stored under `evidence/raw/`, generated collector output under -`evidence/derived/`, the human summary under `reports/`, structured diagnostics -under `logs/`, the per-artifact SHA-256 catalog under `hashes/`, and the -machine-readable run manifest under `metadata/`. Manifest schema version `1` -records run identity, action, status, timestamps, the redacted request and -configuration, host facts, resolved plan and fingerprint, collector lifecycle -records, the artifact catalog, errors, and package metadata. Readers fail closed -on absent, non-integer, or unsupported schema versions. The manifest separately -records package layout version `1.0` and every section path; the package SHA-256 -remains an external sidecar because an archive cannot contain its own final digest. -Every shipped collector's stable filename or path pattern, MIME format, canonical -package location, and retention behavior is defined in [OUTPUTS.md](docs/OUTPUTS.md) -and enforced by the isolated artifact writer. -Supported legacy inputs, their exact v4 replacements, and removed internal shims -are documented in [MIGRATION.md](docs/MIGRATION.md). -The executable evidence for every supported mode, maintenance action, power -action, cancellation, and permission flow is indexed in [FLOW_MATRIX.md](docs/FLOW_MATRIX.md). - -## Requirements - -- Python 3.11 or later. -- Windows for core collection and live platform integration checks. - -The v4 core uses only the Python standard library. Extension collectors declare -their own dependency and capability requirements. - -Collectors reach Windows through `logicytics.platform_adapters`: guarded process -execution covers PowerShell, WMI/CIM, WMIC, and command tools; dedicated adapters -cover registry reads, host filesystem discovery and staging, socket access, and -Win32 library loading. Privilege and UAC inspection remains in the application -environment boundary. Core collectors may use these mockable service seams and -`logicytics.contracts`, but cannot import orchestration services or bypass the -artifact writer for publication. - -Logging uses path-keyed, process-local singleton factories. The CLI and supervisor -share one application logger per canonical `Logicytics.log`; engine and collector -workers share the same event-logger factory while receiving distinct run-owned -JSONL channels. Logger objects are intentionally not shared across process -boundaries, preserving collector isolation and avoiding cross-process mutable state. - -## Installation - -Clone the repository on Windows and use Python 3.11 or later. No third-party -runtime package is required: +The installer is the only Logicytics command designed to run outside a virtual environment. It prepares `.venv` and creates the single root configuration file, `logicytics.yaml`. ```powershell -git clone https://github.com/DefinetlyNotAI/Logicytics.git -cd Logicytics -python -m logicytics preflight +python -m logicytics.cli.installer +.\.venv\Scripts\python.exe -m logicytics preflight ``` -`preflight` is read-only. It validates configuration, host prerequisites, -collector contracts, optional Windows features, and quarantine state before a -run. A missing optional WMIC, BitLocker, or Sysinternals capability is reported -without invalidating unrelated collectors. - -## Quick start - -Inspect the exact plan first, then acknowledge authorization explicitly: +When preflight is clean, plan before collecting: ```powershell -python -m logicytics plan --profile standard -python -m logicytics run --profile standard --acknowledge-authorization +.\.venv\Scripts\python.exe -m logicytics plan --profile standard +.\.venv\Scripts\python.exe -m logicytics run --profile standard --acknowledge-authorization ``` -The standard profile is intentionally bounded. Use `--mode thorough` for the full -deep catalog and approve every requested capability deliberately. Generated run -data appears under `output/data/run-/`; the final summary prints collector -status, duration, failures, package path, and package hash. - -## CLI reference - -Run these from the repository root: +Use the bundled runner for the complete dynamically discovered test suite: ```powershell -python -m logicytics preflight -python -m logicytics debug -python -m logicytics dev -python -m logicytics update --launch-action debug --new-window -python -m logicytics --match "collect quickly" -python -m logicytics usage -python -m logicytics --modes -python -m logicytics collector core.system.system_info --acknowledge-authorization -python -m logicytics plan --profile standard -python -m logicytics run --profile standard --acknowledge-authorization -``` - -`preflight` reports valid and quarantined collectors. `plan` resolves the exact -collector order without collecting data. `run` requires an explicit authorization -acknowledgement and writes each run below `output/data/run-/`. Its verified -ZIP package and SHA-256 sidecar use the action, UTC request timestamp, and run -ID, and are written into that run's `packages/` and `hashes/` directories; -collector workspaces and `logs/` remain run-scoped. Explicit reruns use a -`rerun--run-.zip` package identity. -`collector ` runs only that validated collector and any declared -dependencies through the same authorization, isolation, artifact, manifest, -packaging, and cleanup pipeline; it never adds unrelated profile members. -Add `--interactive` to `run` when a transient command window should pause on the -final status; noninteractive and automated runs never prompt. - -`debug` writes redacted environment, preflight, Sysinternals, Python-support, -version, and configured file-integrity diagnostics to -`output/logs/debug/debug.json`. `dev` compares the repository with the optional -integrity manifest and checks source organization without running collectors. -Manifest changes are opt-in: use `dev --write-manifest --next-version 4.1.0`, or -`dev --interactive` to review status markers and confirm the write. -For a checkout still using the historical INI fallback, that same confirmed dev -action atomically updates only `[System Settings]` `version` and `files` in -`CODE/config.ini`, preserving comments and unrelated sections. Modern checkouts -continue to write the hashed JSON integrity manifest. -On Windows, an update workflow can explicitly open a fresh visible console for -one allowlisted maintenance action with `update --launch-action preflight ---new-window`. Both options are required together; ordinary update checks never -launch another process. - -Collectors that request additional access must be explicitly approved, for -example: - -```powershell -python -m logicytics run --profile standard --acknowledge-authorization ` - --allow-capability filesystem_read -``` - -Important global and run controls: - -| Control | Purpose | -|---------------------------------|------------------------------------------------------------------------------------| -| `--config PATH` | Load a schema-v4 JSON or supported legacy INI configuration. | -| `--match TEXT` | Suggest the closest documented action; history persists only when configured. | -| `--modes` | Print the versioned collector/mode matrix. | -| `run --mode NAME` | Select one canonical execution mode. | -| `--include ID` / `--exclude ID` | Override profile membership by exact collector ID. | -| `--plugins` / `--mods` | Opt into discovered plugins or declared MODs. | -| `--workers COUNT` | Override bounded concurrency within the configured maximum. | -| `--sequential` / `--parallel` | Select explicit scheduling without a legacy mode alias. | -| `--rerun-from MANIFEST` | Rerun selected collectors while preserving parent-run provenance. | -| `--no-package` | Retain the manifest/run tree without a ZIP or ZIP hash. | -| `--reboot` / `--shutdown` | Schedule one mutually exclusive power action only after verified packaging. | -| `update --apply` | Explicitly apply the configured Git update; an ordinary update check is read-only. | - -Legacy `--default`, `--threaded`, `--minimal`, `--depth`, `--modded`, `--nopy`, -and `--performance-check` flags remain exact aliases. They cannot be combined -with a contradictory mode, profile, or scheduling override. - -## Authorization, permissions, and status - -Collection requires `--acknowledge-authorization`. Capabilities such as -filesystem reads, registry reads, subprocesses, browser data, private keys, -sensitive files, packet capture, network access, or elevation must also be -approved explicitly when selected metadata requests them. An elevated collector -is rejected during planning unless the process is actually administrative. - -Collector outcomes are `succeeded`, `partial`, `skipped`, `cancelled`, or -`failed`. Expected absence and access denial are visible skips when no evidence -can be collected; unexpected command, parsing, timeout, output-limit, or worker -failures remain failures. One collector failure does not stop independent peers, -and the run cannot appear fully successful while a selected collector failed. - -Ctrl+C requests cooperative cancellation. Long collectors check the run-owned -cancellation marker, the supervisor terminates unresponsive worker trees within -their timeout policy, and a recoverable partial manifest/package is finalized -when possible. Cleanup removes only worker-owned temporary data. - -CLI exit status is `0` for the requested successful/read-only action, `1` for an -unsuccessful collection or maintenance result, and `2` for invalid arguments, -configuration, preflight, authorization, or other Logicytics contract errors. - -## Collection profiles - -- `minimal`: only collectors that explicitly declare essential local inventory. -- `standard`: shipped core collectors that explicitly declare standard membership. -- `deep`: extended collector-declared inventory; sensitive capabilities still - require explicit approval. -- `offline`: collector-declared local inventory; network and packet-capture - capabilities remain forbidden even when explicitly included and approved. - -Plugin collectors never join any profile unless explicitly included or enabled -with `--plugins`. Unknown profile names are rejected before collection begins. -Python MODs are workspace-confined unless the user explicitly approves the -`filesystem_write` capability. Native PowerShell, batch, and executable MODs -must declare and receive that approval because their host writes cannot be -enforced through Python audit hooks. - -## Execution modes - -`python -m logicytics --modes` runs strict collector preflight and prints the -authoritative, versioned machine-readable mode matrix. Its `modes` rows contain -the exact validated `collector_ids` selected by each mode. Its `collectors` rows -represent every discovered core, plugin, and MOD collector exactly once with its -mode assignments, validation state, execution type, and either `manual_only` or -quarantine status. New integrations should use `run --mode `; the -historical flags remain exact compatibility aliases. - -| Mode | Profile | Scheduling | Legacy alias | Additional behavior | -|---------------|----------|------------------|-----------------------|-------------------------------| -| `standard` | standard | sequential | `--default` | deterministic standard run | -| `balanced` | standard | bounded parallel | `--threaded` | configured worker pool | -| `quick` | minimal | configured | `--minimal` | essential inventory | -| `thorough` | deep | configured | `--depth` | extended slower inventory | -| `offline` | offline | configured | none | forbids network access | -| `extensions` | standard | configured | `--modded` | enables declared MODS | -| `non-python` | standard | configured | `--nopy` | only non-Python MODS payloads | -| `performance` | standard | sequential | `--performance-check` | duration report | - -Explicit `--profile`, `--include`, and `--exclude` remain available for advanced -planning. A named mode cannot be combined with a different explicit profile or -with its contradictory worker strategy. - -## Public Python API - -Importing `logicytics` does not start collection, create output directories, or -load the runtime supervisor. Application services are loaded only when their -public names are requested: - -```python -from pathlib import Path - -from logicytics import RunRequest, load_configuration, open_artifact, plan_run, query_run, read_artifact, run_collection - -root = Path.cwd() -configuration = load_configuration(root) -request = RunRequest(max_workers=1, acknowledge_authorization=True) -plan = plan_run(root, request, configuration=configuration) -outcome = run_collection(root, request, configuration=configuration) -snapshot = query_run(root, outcome.manifest.run_id, configuration=configuration) -for collector in snapshot.collectors: - print(collector.collector_id, collector.status, collector.duration_seconds, collector.summary) - if collector.failure is not None: - print(collector.failure.operation, collector.failure.remediation) -contents = read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration) -# Explicit user-facing actions may open the verified artifact with its Windows handler: -# open_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration) -``` - -Planning enforces strict core/plugin preflight and configured worker bounds -without creating evidence. Run queries validate the manifest, collector-owned -artifact catalog, per-collector timestamps, durations, summaries, errors, -actionable failure details, and configured output boundaries. The command-line -run summary prints the same redacted per-collector status, duration, summary, -and failure guidance. Artifact reads are bounded (16 MiB by default, never more -than 64 MiB) and verify the exact registered bytes against their manifest -SHA-256 before returning evidence. -`open_artifact` applies the same run ownership and hash checks with streaming I/O -before asking Windows to open the file through its associated application. - -## Configuration - -The complete field reference, limits, source precedence, migration rules, and a -parser-verified example are in [CONFIGURATION.md](docs/CONFIGURATION.md). - -An optional project-root `logicytics.json` uses schema version `4`. Product -policy is split into immutable `runtime`, `interaction`, `maintenance`, and -`logging` sections. Profile membership remains in the typed collector registry, -per-collector options remain under `collectors`, and invocation-only overrides -such as explicit selections, worker strategy, authorization, and capabilities -remain in `RunRequest`; none of those runtime overrides mutate the loaded -configuration. - -```json -{ - "schema_version": 4, - "runtime": {"default_max_workers": 4, "maximum_workers": 8}, - "maintenance": { - "local_manifest_path": "project.manifest.json", - "minimum_python": "3.11", - "recommended_python": "3.11" - }, - "logging": { - "level": "INFO", - "console_enabled": true, - "color_enabled": true, - "file_enabled": true, - "maximum_bytes": 4194304, - "delete_previous": false, - "retention_days": 30 - }, - "collectors": { - "core.filesystem.system_drive_tree": {"max_entries": 5000, "max_depth": 12}, - "core.process.memory_map": { - "max_regions": 5000, - "output_limit_bytes": 67108864, - "disk_safety_margin_bytes": 104857600, - "dump_directory": "memory_maps" - } - } -} +.\.venv\Scripts\python.exe -m logicytics.cli.tests ``` -Shipped filesystem, network, packet, sensitive-inventory, and metadata-only -memory-map collector options are strictly typed and bounded before planning. -The memory-map output limit, free-space safety margin, and dump directory are -collector-scoped; the directory must be relative to that collector's isolated -workspace. -Unknown engine fields, duplicate JSON keys, unsafe workspace paths, and invalid -collector IDs fail closed; plugin-owned setting fields remain extensible. - -An optional remote integrity manifest requires both an HTTPS -`remote_manifest_url` and a pinned lowercase `remote_manifest_sha256`. The exact -downloaded bytes are authenticated before strict JSON parsing. Integrity -manifests can contain only a schema version, project version, and file hashes; -they cannot configure, enable, or add collectors, and collection never depends -on the remote endpoint. - -Application logging supports `DEBUG`, `INTERNAL`, `INFO`, `WARNING`, `ERROR`, -`EXCEPTION`, and `CRITICAL`. Human-readable UTC rows are redacted before they -reach colored stderr or `output/logs/Logicytics.log`; per-run and per-collector -JSONL logs remain isolated in each run. The configured byte limit retains recent -complete rows, `delete_previous` removes the prior application log explicitly, -and `retention_days` removes expired rotated application logs. - -Schema-version `3` JSON files are migrated in memory without modifying the source -file. Legacy `workers`/`worker_count`, `max_workers`, `output_root`, and -`collector_settings` aliases become their v4 runtime or collector equivalents; -the old `ACCESS/RUNS` default becomes `output/data`. Ambiguous aliases, unsafe -values, unsupported versions, and implicit plugin-selection fields are rejected. -Run manifests record `migrated_from_schema: 3` when this compatibility path runs. -When `logicytics.json` is absent, the historical `CODE/config.ini` is also loaded -as a read-only migration source. Its general logging/worker/history options and -Flag, DumpMemory, NetWorkPsutil, and PacketSniffer sections are converted into -the same bounded typed v4 sections. Modern JSON always takes precedence; an INI -larger than 2 MiB, malformed values, and unsafe limits fail before planning. - -Deep-profile compatibility includes `core.integration.legacy_code_outputs`. -When a historical `CODE/` directory exists, it imports only bounded generated -text, CSV, JSON, XML, HTML, DOT, SVG, registry, event-log, log, and ZIP evidence -through the normal artifact catalog. Python/PowerShell/batch source, executables, -models, `config.ini`, caches, virtual environments, and library internals are -never staged or packaged. Clean v4 checkouts skip this collector because they do -not need the legacy directory. - -## Collector rules - -Core collectors must live at `core//.py`. Each module -must expose exactly one public class named `Collector`, inherit -from `CoreCollector`, provide documented and typed `metadata`, `validate`, -`collect`, and `cleanup` methods, and use a matching ID/specialty. Plugins follow -the equivalent `PluginCollector` contract. - -Every collector explicitly declares the exact MIME types it can register. - -## Migration status - -Historical Python, PowerShell, batch, and executable scripts enter v4 only as -sidecar-declared MOD compatibility collectors; they never bypass preflight, -planning, isolation, capability approval, or artifact registration. Historical -generated CODE evidence has its own bounded compatibility collector. Every -shipped core collector writes beneath `CollectorContext.workspace`, publishes -through `context.artifacts.register_file`, and avoids mutable `global`/`nonlocal` -state. The run artifact tree is canonical even when a compatibility collector -preserves a legacy evidence filename or nested relative path. -Preflight statically compares literal `register_file(..., media_type=...)` calls -with that declaration, and isolated workers enforce the declaration again on -the returned artifact catalog. The collector folder, ID, class, description, -source category, and primary specialty therefore remain one coherent contract. - -Collectors may import the public collector contracts, but not CLI, planning, -runtime, packaging, configuration, or public application-control services. Even -with an approved subprocess capability, workers cannot invoke Logicytics, another -collector, repository/package managers, or system reboot/shutdown commands. -Generated output is registered as derived evidence by default. Collectors that -preserve source material must explicitly register it with -`evidence_kind=EvidenceKind.RAW`; its manifest record and ZIP section retain that -classification across the isolated worker boundary. - -Malformed core collectors block a run. Malformed unselected plugins are -quarantined and listed by preflight; malformed explicitly selected plugins block -the requested run. See [FEATURE_STATUS.md](docs/FEATURE_STATUS.md) for implemented -ownership and [V4_RELEASE.md](docs/V4_RELEASE.md) for the released feature surface. - -## Verification - -```powershell -python -m unittest discover -v -python -m compileall -q logicytics core tests -``` +## What to expect -On Windows, `python -m unittest tests.test_windows_integration -v` adds bounded -live privilege, registry, PowerShell, CIM/WMI, optional WMIC, event-log, -BitLocker, networking, Sysinternals, and artifact-publication probes. +Each run follows a simple, auditable path: -## Troubleshooting +`request → validated plan → isolated collectors → normalized artifacts → manifest → package` -- **A collector is skipped:** inspect its manifest `summary`, `errors`, and - `failure` fields. Install the named optional Windows feature, run from an - authorized elevated console when required, or approve the exact capability. -- **Planning rejects capabilities:** rerun `plan` first and add only the listed - `--allow-capability` values. Sensitive and elevated access is never inferred. -- **PowerShell execution policy is unavailable:** run `preflight`; confirm - `powershell` is on `PATH`. The engine does not weaken machine policy. -- **WMIC is unavailable:** modern Windows may omit the optional WMIC feature. - `core.system.wmic_inventory` skips explicitly; CIM/WMI collectors continue. -- **A plugin is quarantined:** use `preflight` to read its static/runtime errors. - Unselected invalid plugins do not block core collection. -- **A run was interrupted:** inspect the printed run manifest under - `output/data/run-/metadata/`; cancellation preserves recoverable partial - evidence and removes only private worker scratch data. -- **A package hash does not match:** do not trust or open the package. Use the - adjacent `.sha256` sidecar and rerun collection; package verification fails - closed on modified bytes. -- **Output is unexpectedly large:** lower collector-specific bounds in - [CONFIGURATION.md](docs/CONFIGURATION.md) or use `minimal`/`standard`; the aggregate - run budget prevents unbounded publication. +The console keeps status and progress easy to scan. Detailed command output is boxed; application events are written separately in an aligned, human-readable log. Evidence and package metadata remain with the run they describe. -## Documentation +Sysinternals is enabled by default. Logicytics detects an existing installation or securely downloads and extracts the official archive to its dedicated application location. Set `maintenance.sysinternals_enabled: false` in `logicytics.yaml` to opt out completely. -- [CONFIGURATION.md](docs/CONFIGURATION.md) — complete persistent settings contract. -- [OUTPUTS.md](docs/OUTPUTS.md) — every collector path, format, package path, and - retention rule. -- [MODS.md](docs/MODS.md) — opt-in MOD extension contract. -- [MIGRATION.md](docs/MIGRATION.md) — supported v3 and legacy compatibility boundary. -- [FLOW_MATRIX.md](docs/FLOW_MATRIX.md) — executable evidence for every user flow. -- [FEATURE_STATUS.md](docs/FEATURE_STATUS.md) — TODO section owners and status. -- [V4_RELEASE.md](docs/V4_RELEASE.md) — final v4.0 recreation and release evidence. -- [Logicytics wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) — v4 user, - contributor, configuration, evidence, extension, and troubleshooting guides. -- [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) — contributor and security policy. +## Learn more -## Authorization +The [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki) is the home for setup, usage, configuration, collector development, architecture, outputs, troubleshooting, and security guidance. -Use Logicytics only on systems and data you are authorized to inspect. The v4 -engine requires an explicit acknowledgement before it starts selected collectors. +For contribution and security policies, see [CONTRIBUTING.md](CONTRIBUTING.md), [SECURITY.md](SECURITY.md), and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). ## License -See [LICENSE](LICENSE). +Logicytics is released under the [project license](LICENSE). diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index 7f95f4d9..db50447f 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -7,7 +7,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.ctypes_collector import global_memory_status +from logicytics import global_memory_status class MemorySnapshotCollector(CoreCollector): diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 8d39613c..a40d06b1 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -7,7 +7,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.ctypes_collector import ProcessMemoryCounters, MemoryBasicInformation, get_process_memory_info, \ +from logicytics import ProcessMemoryCounters, MemoryBasicInformation, get_process_memory_info, \ virtual_query, get_mapped_file_name, get_current_process, pointer_value from logicytics.platform_adapters import filesystem_adapter diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index 5988d72e..4b915aa1 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -7,7 +7,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.ctypes_collector import get_logical_drives, ularge_integer, get_disk_free_space, get_drive_type +from logicytics import get_logical_drives, ularge_integer, get_disk_free_space, get_drive_type _DRIVE_TYPES = { 0: "unknown", diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index ace08ad0..2f3637ef 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -8,7 +8,7 @@ from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.ctypes_collector import get_volume_information, get_disk_free_space, ularge_integer, \ +from logicytics import get_volume_information, get_disk_free_space, ularge_integer, \ create_unicode_buffer, dword from logicytics.platform_adapters import windows_api_adapter diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 015a2e28..62ed79db 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,207 +1,9 @@ -# Logicytics v4 configuration +# Configuration -Logicytics uses one strictly validated schema-version `4` JSON document. The -default location is `logicytics.json` in the project root; `--config PATH` selects -another file. The public Python API accepts the same path through -`load_configuration(project_root, config_path)` and the planning/run helpers. +Logicytics has one authoritative user configuration: [`logicytics.yaml`](../logicytics.yaml) at the application root. The installer creates it when needed, repair workflows may update it, and users may edit it directly. -Configuration is immutable product policy. One-off choices such as a profile, -mode, explicit collector include/exclude list, authorization acknowledgement, -approved capabilities, worker override, output policy, rerun source, and power -action belong to `RunRequest` or CLI flags and never rewrite this file. +The file uses a strict, mapping-only YAML subset. Invalid indentation, duplicate keys, unsupported settings, unsafe paths, non-finite numbers, and invalid values stop planning before collectors run. -## Complete example +Key controls include the output root, bounded worker limits, console and file-log policy, collector-specific safe limits, and Sysinternals setup. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction entirely. -Every section is optional except `schema_version`. Omitted values use the defaults -listed below. - -```json -{ - "schema_version": 4, - "runtime": { - "output_root": "output/data", - "default_max_workers": 4, - "maximum_workers": 16, - "package_completed_runs": true, - "maximum_run_output_bytes": 4294967296 - }, - "interaction": { - "history_enabled": false, - "similarity_threshold": 0.55, - "model_name": "stdlib-sequence-matcher", - "model_debug": false - }, - "maintenance": { - "local_manifest_path": "project.manifest.json", - "minimum_python": "3.11", - "recommended_python": "3.11" - }, - "logging": { - "level": "INFO", - "console_enabled": true, - "color_enabled": true, - "file_enabled": true, - "maximum_bytes": 4194304, - "delete_previous": false, - "retention_days": 30 - }, - "collectors": { - "core.network.bandwidth_sample": { - "sample_count": 3, - "interval_seconds": 1.0 - }, - "core.filesystem.system_drive_tree": { - "max_entries": 5000, - "max_depth": 12 - } - } -} -``` - -## Source selection and precedence - -1. An explicit `--config PATH` or API `config_path` is loaded. -2. Otherwise, project-root `logicytics.json` is loaded when present. -3. Otherwise, historical `CODE/config.ini` is translated in memory when present. -4. Otherwise, safe schema-v4 defaults are used. - -The modern JSON file always wins over the INI fallback. Loading never modifies -the selected source. A configuration file larger than 2 MiB, malformed UTF-8 or -JSON, duplicate object key, non-finite number, wrong type, unknown engine field, -or unsupported schema fails before planning or collection. - -## Root object - -| Setting | Required | Meaning | -|------------------|----------|---------------------------------------------------------------------------------| -| `schema_version` | yes | Integer `4`; schema `3` is accepted only through the documented migration path. | -| `runtime` | no | Engine-wide paths, concurrency, packaging, and aggregate-output limits. | -| `interaction` | no | Local semantic matching and opt-in history behavior. | -| `maintenance` | no | Integrity manifests and supported Python policy. | -| `logging` | no | Human application-log and console presentation policy. | -| `collectors` | no | A map from exact collector IDs to isolated collector settings. | - -Unknown root fields are rejected. In particular, configuration cannot implicitly -enable plugins, MODs, collectors, capabilities, authorization, or a power action. - -## Runtime settings - -| Setting | Default | Accepted value | Effect | -|----------------------------|----------------------|--------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| -| `output_root` | `output/data` | Non-empty absolute path, or a path resolved relative to the project root | Owns run directories, evidence, manifests, packages, and hashes. | -| `default_max_workers` | `4` | Integer from 1 to 64, not greater than `maximum_workers` | Worker count used when the request/mode does not force a strategy. | -| `maximum_workers` | `16` | Integer from 1 to 64, not less than `default_max_workers` | Hard ceiling for CLI and API worker overrides. | -| `package_completed_runs` | `true` | Boolean | When false, publish the run tree and manifest without creating a ZIP or ZIP hash. | -| `maximum_run_output_bytes` | `4294967296` (4 GiB) | Integer from 1 through `68719476736` (64 GiB) | Aggregate artifact budget shared by the run; later collectors fail or skip predictably when exhausted. | - -Completed run evidence is retained until the user explicitly removes its run -directory. The logging retention setting below applies only to application logs. - -## Interaction settings - -| Setting | Default | Accepted value | Effect | -|------------------------|---------------------------|--------------------------------|----------------------------------------------------------------------------| -| `history_enabled` | `false` | Boolean | Persist compressed local match history only when enabled. | -| `similarity_threshold` | `0.55` | Finite number from 0 through 1 | Minimum direct semantic-match score. | -| `model_name` | `stdlib-sequence-matcher` | Non-empty single-line string | Label recorded with matcher results; v4 uses its standard-library matcher. | -| `model_debug` | `false` | Boolean | Include matcher diagnostics independently of application debug logging. | - -History records remain local and contain the matched flag, input, accuracy, -timestamp, device name, and per-flag usage. Disabling history prevents new -records; it does not silently delete an existing history file. - -## Maintenance settings - -| Setting | Default | Accepted value | Effect | -|--------------------------|-------------------------|----------------------------------------------------------------------|--------------------------------------------------------------------------------------| -| `remote_manifest_url` | none | HTTPS URL, configured together with `remote_manifest_sha256` | Optional integrity-manifest source used by maintenance actions, never by collection. | -| `remote_manifest_sha256` | none | Exactly 64 lowercase hexadecimal characters, configured with the URL | Authenticates the exact downloaded manifest bytes before parsing. | -| `local_manifest_path` | `project.manifest.json` | Non-empty relative path without `..` | Project-confined integrity manifest used by debug/developer checks. | -| `minimum_python` | `3.11` | `major.minor` | Oldest supported interpreter reported by diagnostics. | -| `recommended_python` | `3.11` | `major.minor`, not older than the minimum | Recommended interpreter reported by diagnostics. | - -An integrity manifest may contain only its schema version, project version, and -file hashes. It cannot configure execution. Remote retrieval is optional, -authenticated, bounded, and isolated from normal collection. - -## Logging settings - -| Setting | Default | Accepted value | Effect | -|-------------------|-------------------|------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| -| `level` | `INFO` | `DEBUG`, `INTERNAL`, `INFO`, `WARNING`, `ERROR`, `EXCEPTION`, or `CRITICAL` (case-insensitive) | Minimum application-log level. | -| `console_enabled` | `true` | Boolean | Write redacted human rows to stderr. | -| `color_enabled` | `true` | Boolean | Add ANSI colors only when the console is a TTY. | -| `file_enabled` | `true` | Boolean | Write redacted human rows to `output/logs/Logicytics.log`. | -| `maximum_bytes` | `4194304` (4 MiB) | Integer from 1024 through `67108864` (64 MiB) | Retain recent complete rows within the application-log byte cap. | -| `delete_previous` | `false` | Boolean | Explicitly remove the current application log when the logger starts. | -| `retention_days` | `30` | Integer from 0 through 3650 | Remove expired rotated `Logicytics*.log` files; does not remove run evidence. | - -Engine and collector JSONL event streams are always run-owned and separate from -the human application log. All channels redact sensitive fields before writing. - -## Shipped collector settings - -The engine validates the following core fields before planning. A collector also -revalidates settings inside its isolated worker. Unknown fields for these shipped -collectors are rejected. - -| Collector | Setting | Default | Accepted value | -|--------------------------------------------|----------------------------|---------------------------|-----------------------------------------------------------| -| `core.network.bandwidth_sample` | `sample_count` | `3` | Integer 1-10. | -| `core.network.bandwidth_sample` | `interval_seconds` | `1.0` | Finite number 0.1-60 seconds. | -| `core.packet.packet_capture` | `packet_count` | `100` | Integer 1-10000. | -| `core.packet.packet_capture` | `timeout_seconds` | `10` | Finite number 1-60 seconds. | -| `core.packet.packet_capture` | `retry_window_seconds` | `0` | Finite number 0-60 seconds. | -| `core.packet.packet_capture` | `interface` | local host IPv4 address | Non-empty text without a NUL byte. | -| `core.filesystem.system_drive_tree` | `max_entries` | `5000` | Integer 1-50000. | -| `core.filesystem.system_drive_tree` | `max_depth` | `12` | Integer 1-32. | -| `core.filesystem.system_drive_listing` | `max_entries` | `10000` | Integer 1-50000. | -| `core.filesystem.system_drive_listing` | `max_depth` | `16` | Integer 1-32. | -| `core.filesystem.sensitive_file_inventory` | `root` | Windows system-drive root | Non-empty absolute filesystem path. | -| `core.filesystem.sensitive_file_inventory` | `max_directories` | `5000` | Integer 1-50000. | -| `core.filesystem.sensitive_file_inventory` | `max_matches` | `500` | Integer 1-5000. | -| `core.process.memory_map` | `max_regions` | `5000` | Integer 1-100000. | -| `core.process.memory_map` | `output_limit_bytes` | `67108864` (64 MiB) | Integer 1024-67108864. | -| `core.process.memory_map` | `disk_safety_margin_bytes` | `104857600` (100 MiB) | Integer 0-68719476736. | -| `core.process.memory_map` | `dump_directory` | `memory_maps` | Non-empty relative collector-workspace path without `..`. | - -Shipped core collectors without a table above do not declare configurable fields, -so a non-empty settings object for one is rejected. Syntactically valid plugin- -and MOD-owned fields remain extensible for their extension contracts, but they do -not bypass capability, workspace, artifact, timeout, or output limits. - -## Schema 3 and INI migration - -Schema-version `3` JSON is translated in memory and recorded as -`migrated_from_schema: 3` in the run manifest. Root aliases `workers` and -`worker_count` become `runtime.default_max_workers`; `max_workers` becomes -`runtime.maximum_workers`; `output_root`, `package_completed_runs`, and -`maximum_run_output_bytes` move under `runtime`; and `collector_settings` becomes -`collectors`. Historical `ACCESS/RUNS` resolves to `output/data`. Conflicting old -and new names fail closed instead of choosing one silently. - -The read-only `CODE/config.ini` bridge translates these historical fields: - -- `Settings`: `max_workers`, `log_using_debug`, `delete_old_logs`, and - `save_preferences`. -- `Flag Settings`: `accuracy_min`, `model_to_use`, and `model_debug`. -- `DumpMemory Settings`: `file_size_limit` and `file_size_safety`. -- `NetWorkPsutil Settings`: `sample_count` and `interval`. -- `PacketSniffer Settings`: `interface`, `packet_count`, `timeout`, and - `max_retry_time`. - -Use a schema-v4 JSON file for all new configuration. See [MIGRATION.md](MIGRATION.md) -for the broader v3-to-v4 runtime and extension compatibility boundary. - -## Validation and inspection - -Validate discovery, configuration, and host prerequisites without collecting: - -```powershell -python -m logicytics --config .\logicytics.json preflight -python -m logicytics --config .\logicytics.json plan --profile standard -``` - -The run manifest records a redacted configuration snapshot and deterministic -configuration fingerprint. Use `query_run` to inspect the resolved plan, -collector results, artifacts, failures, and hashes after a run. +For the complete configuration reference and examples, use the [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki). diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index e9347445..8c780660 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -1,58 +1,7 @@ -# Migrating to Logicytics v4 +# Migration -The v4 engine has one canonical data model: a typed request produces an isolated -run tree, registered artifacts, a manifest, and (unless disabled) a verified ZIP -with a SHA-256 sidecar. Compatibility code may translate an old input into that -model, but it may not create a second execution or output pipeline. +Logicytics v4 uses `logicytics.yaml` as its sole settings source and keeps application execution code under `logicytics/module/`. Commands live under `logicytics/cli/`; globally available Windows ctypes infrastructure lives under `logicytics/global/`. -## Supported compatibility bridges +Run `python -m logicytics.cli.installer` to prepare the virtual environment and root YAML configuration, then run `python -m logicytics preflight` from that environment. -These bridges are intentionally supported for the v4 release and are not -internal shims: - -| Historical interface | v4 replacement | Retention policy | -|------------------------------------------------|----------------------------------------|----------------------------------------| -| `--default` | `--mode standard` | Supported v4 CLI alias | -| `--threaded` | `--mode balanced` | Supported v4 CLI alias | -| `--minimal` | `--mode quick` | Supported v4 CLI alias | -| `--depth` | `--mode thorough` | Supported v4 CLI alias | -| `--nopy` | `--mode non-python` | Supported v4 CLI alias | -| `--modded` | `--mode extensions` | Supported v4 CLI alias | -| `--performance-check` | `--mode performance` | Supported v4 CLI alias | -| schema-3 JSON settings | schema-4 `logicytics.json` | Read and migrated in memory | -| `CODE/config.ini` | schema-4 `logicytics.json` | Read-only fallback when JSON is absent | -| generated evidence in `CODE/` | `core.integration.legacy_code_outputs` | Deep-profile import only | -| `.py`, `.ps1`, `.bat`, `.exe` files in `MODS/` | typed plugin collector | Isolated compatibility adapter | - -Legacy inputs never enable plugins or MODS implicitly. Ambiguous settings, -duplicate keys, unsafe paths, undeclared capabilities, and unsupported fields -fail closed. Migration provenance is recorded in the run manifest. - -## Output migration - -There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/` compatibility -copies. Their v4 replacements are the run tree under `output/data/run-/`, -application diagnostics under `output/logs/`, and each run's `packages/` and -`hashes/` directories. `output/data/zip/` and `output/data/hashes/` are created -as stable compatibility landing directories for external tooling but are not -populated with duplicate evidence. The run artifact tree and verified package -layout in [OUTPUTS.md](OUTPUTS.md) are canonical. - -The legacy CODE evidence collector preserves a historical file's relative name -only inside its collector-owned `legacy_code/` subtree. This is the sole legacy -output-name bridge because those names identify user-generated evidence. It -excludes source code, configuration, manifests, models, caches, virtual -environments, and library internals. No compatibility copy is written beside the -canonical artifact. - -## Removed internal shims - -The v4 public API replaces process-global run state, current-directory lookup, -direct collector `exit()` calls, shared cleanup, unregistered output scanning, -and direct packaging of `CODE/`. Collector code must use `CollectorContext`, -`ArtifactWriter`, typed results, and the supervisor lifecycle. These obsolete -internal behaviors are not exposed as aliases and must not be reintroduced. - -New integrations should implement the v4 collector contract directly. A bridge -is accepted only when it has a named owner, bounded input schema, explicit -replacement, isolation tests, and a documented removal or support policy. +Technical migration guidance is maintained in the [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki). diff --git a/docs/TODO.md b/docs/TODO.md index 351c21fe..87d0550d 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -647,14 +647,13 @@ Windows platform allows. - [x] Check whether the process has administrator privileges. - [x] Check the PowerShell execution policy. - [x] Check whether UAC is enabled. -- [x] Detect and extract the bundled Sysinternals archive unless a local ignore - marker opts out. +- [x] Detect, download, and extract Sysinternals when enabled by root YAML. - [x] Report missing, archived, or extracted Sysinternals tools without stopping unrelated collection. ### Configuration -- [x] Load `config.ini` from the project CODE directory. +- [x] Load the root YAML configuration as the sole settings source. - [x] Expose debug level, version, current file manifest, log retention, worker count, preference persistence, and all collector settings. - [x] Support remote configuration retrieval for update/integrity checks. @@ -845,7 +844,7 @@ appropriate default/deep/non-Python mode. one-main-feature-per-file organization. - [x] Compare current files against the configured manifest. - [x] Display added, removed, and unchanged files with colored status markers. -- [x] Update the manifest in `config.ini` after confirmation. +- [x] Update the configured integrity manifest after confirmation. - [x] Prompt for and validate the next semantic version. - [x] Keep excluded history/cache/Sysinternals files out of manifest comparisons. diff --git a/docs/V4_RELEASE.md b/docs/V4_RELEASE.md index 1a1995a6..b39bfcfb 100644 --- a/docs/V4_RELEASE.md +++ b/docs/V4_RELEASE.md @@ -18,7 +18,7 @@ wiki commit `eaf7628`. - Package version: `4.0.0`. - Collector contract: `4.0`. -- Configuration schema: `4` with bounded schema-3 JSON and `CODE/config.ini` +- Configuration schema: `4` with a validated root `logicytics.yaml` migration. - Run manifest schema: `1`. - Package layout: `1.0`. diff --git a/logicytics.yaml b/logicytics.yaml new file mode 100644 index 00000000..052e8cc0 --- /dev/null +++ b/logicytics.yaml @@ -0,0 +1,27 @@ +# Logicytics user configuration +schema_version: 4 +runtime: + output_root: output/data + default_max_workers: 4 + maximum_workers: 16 + package_completed_runs: true +interaction: + history_enabled: false + similarity_threshold: 0.55 + model_name: stdlib-sequence-matcher + model_debug: false +maintenance: + local_manifest_path: project.manifest.json + minimum_python: "3.11" + recommended_python: "3.11" + sysinternals_enabled: true + sysinternals_download_url: https://download.sysinternals.com/files/SysinternalsSuite.zip +logging: + level: INFO + console_enabled: true + color_enabled: true + file_enabled: true + maximum_bytes: 4194304 + delete_previous: false + retention_days: 30 +collectors: {} diff --git a/logicytics/__init__.py b/logicytics/__init__.py index a23374ce..fd609a86 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -1,5 +1,8 @@ -"""Logicytics v4 public engine API.""" -from logicytics.api import run_collection, read_artifact, query_run, open_artifact, plan_run, load_configuration, \ +"""Logicytics v4 public contracts and lazily loaded application API.""" + +import importlib + +from logicytics.module.api import run_collection, read_artifact, query_run, open_artifact, plan_run, load_configuration, \ RunSnapshot, CollectorSnapshot, CollectorFailureSnapshot from logicytics.contracts import ( CONTRACT_VERSION, @@ -31,11 +34,16 @@ def __getattr__(name: str): - """Load application services only when callers explicitly request them.""" + """Load application services and global host infrastructure on explicit access.""" if name in _APPLICATION_EXPORTS: - from logicytics import api + from logicytics.module import api return getattr(api, name) + if name == "ctypes_collector": + return importlib.import_module("logicytics.global.ctypes_collector") + global_infrastructure = importlib.import_module("logicytics.global.ctypes_collector") + if hasattr(global_infrastructure, name): + return getattr(global_infrastructure, name) raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py new file mode 100644 index 00000000..004a22b2 --- /dev/null +++ b/logicytics/cli/__init__.py @@ -0,0 +1,5 @@ +"""User-facing Logicytics command entry points.""" + +from logicytics.cli.commands import CLI, cli_methods, main + +__all__ = ["CLI", "cli_methods", "main"] diff --git a/logicytics/cli/__main__.py b/logicytics/cli/__main__.py new file mode 100644 index 00000000..a9d83714 --- /dev/null +++ b/logicytics/cli/__main__.py @@ -0,0 +1,7 @@ +"""Run the normal Logicytics CLI with ``python -m logicytics.cli``.""" + +from logicytics.cli.commands import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/logicytics/cli.py b/logicytics/cli/commands.py similarity index 91% rename from logicytics/cli.py rename to logicytics/cli/commands.py index 9eba31f3..90753e2c 100644 --- a/logicytics/cli.py +++ b/logicytics/cli/commands.py @@ -1,4 +1,4 @@ -"""Minimal command-line interface for v4 planning and supervised execution.""" +"""Command-line orchestration for v4 planning and supervised execution.""" from __future__ import annotations @@ -12,42 +12,75 @@ from pathlib import Path from typing import Literal -from logicytics.configuration import AppConfig, load_config +from logicytics.module.configuration import AppConfig, load_config from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest -from logicytics.discovery import preflight -from logicytics.environment import inspect_environment -from logicytics.errors import LogicyticsError -from logicytics.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph -from logicytics.logging import get_application_logger -from logicytics.maintenance import ( +from logicytics.module.discovery import preflight +from logicytics.module.environment import inspect_environment +from logicytics.module.errors import LogicyticsError +from logicytics.module.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph +from logicytics.module.logging import get_application_logger +from logicytics.module.maintenance import ( build_manifest, compare_files, developer_checks, load_local_manifest, local_version, maintenance_diagnostics, - write_legacy_ini_manifest, write_local_manifest, ) -from logicytics.manifest import MANIFEST_SCHEMA_VERSION -from logicytics.modes import ( +from logicytics.module.manifest import MANIFEST_SCHEMA_VERSION +from logicytics.module.modes import ( EXECUTION_MODES, LEGACY_MODE_ALIASES, ExecutionStrategy, mode_matrix, resolve_execution_mode, ) -from logicytics.output_layout import ensure_output_layout -from logicytics.planner import BUILTIN_PROFILES, build_plan +from logicytics.module.output_layout import ensure_output_layout +from logicytics.module.planner import BUILTIN_PROFILES, build_plan from logicytics.platform_adapters import process_adapter -from logicytics.runtime import RunSupervisor -from logicytics.sysinternals import ensure_sysinternals +from logicytics.module.runtime import RunSupervisor +from logicytics.module.sysinternals import ensure_sysinternals class CLI: + @staticmethod + def render_preflight( + logger: object, + validation: dict[str, list[dict[str, object]]], + sysinternals: dict[str, str], + ) -> None: + """Present validated collectors and diagnostics without exposing internal JSON.""" + valid = validation["valid"] + invalid = validation["invalid"] + quarantined = validation["quarantined"] + logger.box( + "Preflight", + ( + f"Valid collectors: {len(valid)}", + f"Quarantined extensions: {len(quarantined)}", + f"Blocking failures: {len(invalid)}", + f"Sysinternals: {sysinternals['status']}", + ), + ) + for item in (*invalid, *quarantined): + diagnostics = item.get("diagnostics", []) + details = "; ".join( + str(diagnostic.get("message", "invalid collector")) + for diagnostic in diagnostics + if isinstance(diagnostic, dict) + ) + logger.event( + "ERROR" if item in invalid else "WARNING", + details or "collector validation failed", + source="logicytics.cli", + collector=str(item["id"]), + ) + @staticmethod def project_root() -> Path: - return Path(__file__).resolve().parent.parent + """Return the repository root from the relocated CLI package.""" + return Path(__file__).resolve().parents[2] @staticmethod def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: @@ -258,7 +291,7 @@ def parser() -> argparse.ArgumentParser: parser.add_argument( "--config", type=Path, - help="Path to a v4 JSON or supported legacy INI configuration file", + help="Path to the authoritative Logicytics YAML configuration file", ) parser.add_argument("--usage", action="store_true", help="Show local interaction statistics and create a usage graph.") @@ -542,17 +575,11 @@ def run_developer_action( ) manifest = build_manifest(root, settings, next_version) - legacy_ini = root / "CODE" / "config.ini" - modern_config = root / "logicytics.json" - - if legacy_ini.is_file() and not modern_config.exists(): - written_manifest = write_legacy_ini_manifest(root, manifest) - else: - written_manifest = write_local_manifest( - root, - settings, - manifest, - ) + written_manifest = write_local_manifest( + root, + settings, + manifest, + ) manifest_path = str(written_manifest) @@ -573,6 +600,13 @@ def run_developer_action( def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" + if sys.prefix == sys.base_prefix: + print( + "ERROR | Logicytics must run inside a virtual environment. " + "Run python -m logicytics.cli.installer first.", + file=sys.stderr, + ) + return 2 cli_parser = cli_methods.parser() arguments = cli_parser.parse_args(argv) @@ -620,6 +654,7 @@ def main(argv: list[str] | None = None) -> int: application_logger.event( "INFO", "command_started", + source="logicytics.cli", command=arguments.command, ) @@ -729,19 +764,8 @@ def main(argv: list[str] | None = None) -> int: enable_mods=arguments.mods, ) - payload: dict[str, object] = { - "environment": inspect_environment().to_dict(), - "sysinternals": ensure_sysinternals(root).to_dict(), - **validation, - } - - print( - json.dumps( - payload, - indent=2, - sort_keys=True, - ) - ) + sysinternals = ensure_sysinternals(root, configuration.maintenance).to_dict() + cli_methods.render_preflight(application_logger, validation, sysinternals) return 0 if not validation["invalid"] else 2 @@ -768,7 +792,7 @@ def main(argv: list[str] | None = None) -> int: "cpu_count": os.cpu_count(), }, "sysinternals": ( - ensure_sysinternals(root).to_dict() + ensure_sysinternals(root, configuration.maintenance).to_dict() ), "preflight": { "valid_collectors": len(report.valid), diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py new file mode 100644 index 00000000..a2102319 --- /dev/null +++ b/logicytics/cli/installer.py @@ -0,0 +1,38 @@ +"""Bootstrap the supported virtual environment and root YAML configuration.""" + +from __future__ import annotations + +import argparse +import sys +import venv +from pathlib import Path + +from logicytics.module.configuration import write_default_configuration + + +def project_root() -> Path: + """Return the repository root without importing the normal application CLI.""" + return Path(__file__).resolve().parents[2] + + +def main(argv: list[str] | None = None) -> int: + """Create the local virtual environment and initialize YAML configuration.""" + parser = argparse.ArgumentParser(description="Prepare a Logicytics installation.") + parser.add_argument("--environment", type=Path, default=Path(".venv"), help="Virtual environment directory.") + parser.add_argument("--overwrite-config", action="store_true", help="Replace the root YAML template.") + arguments = parser.parse_args(argv) + root = project_root() + environment = arguments.environment if arguments.environment.is_absolute() else root / arguments.environment + if not environment.exists(): + venv.EnvBuilder(with_pip=True).create(environment) + print(f"Created virtual environment: {environment}") + else: + print(f"Using existing virtual environment: {environment}") + configuration = write_default_configuration(root, overwrite=arguments.overwrite_config) + print(f"Configuration ready: {configuration}") + print(f"Run: {environment / 'Scripts' / 'python.exe'} -m logicytics preflight") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py new file mode 100644 index 00000000..c15a3f2f --- /dev/null +++ b/logicytics/cli/tests.py @@ -0,0 +1,39 @@ +"""Discover and execute the complete Logicytics test suite.""" + +from __future__ import annotations + +import argparse +import sys +import unittest +from pathlib import Path + + +def _require_virtual_environment() -> None: + """Reject normal tooling execution outside the environment prepared by installer.""" + if sys.prefix == sys.base_prefix: + raise RuntimeError("Logicytics tests require a virtual environment. Run logicytics.cli.installer first.") + + +def main(argv: list[str] | None = None) -> int: + """Dynamically discover tests and return a CI-appropriate result code.""" + parser = argparse.ArgumentParser(description="Run all discovered Logicytics tests.") + parser.add_argument("--verbosity", type=int, choices=(0, 1, 2), default=2) + arguments = parser.parse_args(argv) + try: + _require_virtual_environment() + except RuntimeError as error: + print(f"ERROR | {error}", file=sys.stderr) + return 2 + root = Path(__file__).resolve().parents[2] + result = unittest.TextTestRunner(verbosity=arguments.verbosity).run( + unittest.defaultTestLoader.discover(str(root / "tests"), top_level_dir=str(root)) + ) + print( + f"Result: failures={len(result.failures)} errors={len(result.errors)} skipped={len(result.skipped)} " + f"tests={result.testsRun}" + ) + return 0 if result.wasSuccessful() else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/logicytics/global/__init__.py b/logicytics/global/__init__.py new file mode 100644 index 00000000..22912bab --- /dev/null +++ b/logicytics/global/__init__.py @@ -0,0 +1,6 @@ +"""Globally available, host-bound infrastructure loaded only by explicit consumers. + +The directory name follows the product architecture. Because ``global`` is a +Python keyword, consumers load submodules through the public root exports or +``importlib.import_module("logicytics.global.")``. +""" diff --git a/logicytics/ctypes_collector.py b/logicytics/global/ctypes_collector.py similarity index 100% rename from logicytics/ctypes_collector.py rename to logicytics/global/ctypes_collector.py diff --git a/logicytics/module/__init__.py b/logicytics/module/__init__.py new file mode 100644 index 00000000..c6ac7fee --- /dev/null +++ b/logicytics/module/__init__.py @@ -0,0 +1 @@ +"""Application services that plan, execute, package, and maintain Logicytics runs.""" diff --git a/logicytics/api.py b/logicytics/module/api.py similarity index 98% rename from logicytics/api.py rename to logicytics/module/api.py index 43565566..8eae9726 100644 --- a/logicytics/api.py +++ b/logicytics/module/api.py @@ -13,13 +13,13 @@ from pathlib import Path, PurePosixPath from typing import Any -from logicytics.configuration import AppConfig, load_config +from logicytics.module.configuration import AppConfig, load_config from logicytics.contracts import Artifact, CollectorStatus, RunRequest, RunStatus -from logicytics.discovery import preflight -from logicytics.errors import ArtifactError, PlanError -from logicytics.manifest import MANIFEST_SCHEMA_VERSION -from logicytics.planner import RunPlan, build_plan -from logicytics.runtime import RunOutcome, RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.errors import ArtifactError, PlanError +from logicytics.module.manifest import MANIFEST_SCHEMA_VERSION +from logicytics.module.planner import RunPlan, build_plan +from logicytics.module.runtime import RunOutcome, RunSupervisor _RUN_ID = re.compile(r"run-[0-9a-f]{32}") _ARTIFACT_ID = re.compile(r"artifact\.[0-9a-f]{32}") diff --git a/logicytics/artifacts.py b/logicytics/module/artifacts.py similarity index 99% rename from logicytics/artifacts.py rename to logicytics/module/artifacts.py index 4b79a390..36e5fef6 100644 --- a/logicytics/artifacts.py +++ b/logicytics/module/artifacts.py @@ -13,7 +13,7 @@ from uuid import uuid4 from logicytics.contracts import Artifact, ArtifactWriter, EvidenceKind -from logicytics.errors import ArtifactError +from logicytics.module.errors import ArtifactError def _is_within(path: Path, parent: Path) -> bool: diff --git a/logicytics/command_runner.py b/logicytics/module/command_runner.py similarity index 100% rename from logicytics/command_runner.py rename to logicytics/module/command_runner.py diff --git a/logicytics/configuration.py b/logicytics/module/configuration.py similarity index 70% rename from logicytics/configuration.py rename to logicytics/module/configuration.py index c9510d75..22bcd33e 100644 --- a/logicytics/configuration.py +++ b/logicytics/module/configuration.py @@ -2,7 +2,6 @@ from __future__ import annotations -import configparser import hashlib import json import re @@ -12,8 +11,8 @@ from types import MappingProxyType from typing import Any, Mapping -from logicytics.errors import PlanError -from logicytics.redaction import redact_mapping +from logicytics.module.errors import PlanError +from logicytics.module.redaction import redact_mapping SCHEMA_VERSION = 4 MAXIMUM_CONFIGURATION_BYTES = 2 * 1024 * 1024 @@ -32,22 +31,15 @@ _INTERACTION_FIELDS = frozenset({"history_enabled", "similarity_threshold", "model_name", "model_debug"}) _MAINTENANCE_FIELDS = frozenset({ "remote_manifest_url", "remote_manifest_sha256", "local_manifest_path", - "minimum_python", "recommended_python", + "minimum_python", "recommended_python", "sysinternals_enabled", "sysinternals_download_url", }) _LOGGING_FIELDS = frozenset({ "level", "console_enabled", "color_enabled", "file_enabled", "maximum_bytes", "delete_previous", "retention_days", }) _LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION"}) -_LEGACY_ROOT_FIELDS = _ROOT_FIELDS.union({ - "collector_settings", "workers", "worker_count", "max_workers", "output_root", - "package_completed_runs", "maximum_run_output_bytes", -}) -_LEGACY_RUNTIME_ALIASES = MappingProxyType({ - "workers": "default_max_workers", - "worker_count": "default_max_workers", - "max_workers": "maximum_workers", -}) +DEFAULT_CONFIGURATION_FILENAME = "logicytics.yaml" +DEFAULT_SYSINTERNALS_DOWNLOAD_URL = "https://download.sysinternals.com/files/SysinternalsSuite.zip" type CollectorSettingValue = str | int | float type CollectorSettings = Mapping[str, CollectorSettingValue] @@ -176,127 +168,117 @@ def _finite_json_number(value: str) -> float: return result -def _assign_migrated_runtime(runtime: dict[str, Any], target: str, value: Any, source: str) -> None: - """Refuse legacy aliases that would silently override another runtime setting.""" - if target in runtime: - raise PlanError(f"legacy configuration contains conflicting settings for {target}: {source}") - runtime[target] = value - - -def _migrate_v3_configuration(raw: Mapping[str, Any], project_root: Path) -> dict[str, Any]: - """Upgrade the explicitly supported v3 shape without writing or activating collectors.""" - unknown = sorted(set(raw) - _LEGACY_ROOT_FIELDS) - if unknown: - raise PlanError(f"legacy configuration contains unsupported root settings: {', '.join(unknown)}") - runtime_value = raw.get("runtime", {}) - if not isinstance(runtime_value, dict): - raise PlanError("legacy runtime configuration must be an object") - runtime: dict[str, Any] = {} - for key, value in runtime_value.items(): - _assign_migrated_runtime(runtime, _LEGACY_RUNTIME_ALIASES.get(key, key), value, f"runtime.{key}") - root_aliases = { - "workers": "default_max_workers", - "worker_count": "default_max_workers", - "max_workers": "maximum_workers", - "output_root": "output_root", - "package_completed_runs": "package_completed_runs", - "maximum_run_output_bytes": "maximum_run_output_bytes", - } - for source, target in root_aliases.items(): - if source in raw: - _assign_migrated_runtime(runtime, target, raw[source], source) - if "collectors" in raw and "collector_settings" in raw: - raise PlanError("legacy configuration contains conflicting collectors and collector_settings sections") - collectors = raw.get("collectors", raw.get("collector_settings", {})) - output_root = runtime.get("output_root") - if isinstance(output_root, str): - candidate = Path(output_root) - relative_parts = tuple(part.casefold() for part in candidate.parts) - if relative_parts == ("access", "runs") or candidate == project_root / "ACCESS" / "RUNS": - runtime["output_root"] = str(project_root / "output" / "data") - return { - "schema_version": SCHEMA_VERSION, - "runtime": runtime, - "interaction": raw.get("interaction", {}), - "maintenance": raw.get("maintenance", {}), - "logging": raw.get("logging", {}), - "collectors": collectors, - } - - -def _legacy_ini_payload(path: Path) -> dict[str, Any]: - """Translate the bounded historical CODE/config.ini schema into typed v4 sections.""" - try: - payload = path.read_bytes() - except OSError as error: - raise PlanError(f"invalid legacy configuration file {path}: {error}") from error - if len(payload) > MAXIMUM_CONFIGURATION_BYTES: - raise PlanError("legacy configuration exceeds the 2 MiB limit") +def _yaml_scalar(value: str, *, line_number: int) -> object: + """Parse the safe scalar subset supported by the root configuration format.""" + if not value: + return {} + if value in {"null", "Null", "NULL", "~"}: + return None + if value in {"true", "True", "TRUE"}: + return True + if value in {"false", "False", "FALSE"}: + return False + if value == "{}": + return {} + if value.startswith(("\"", "'")): + if not value.endswith(value[0]): + raise PlanError(f"invalid YAML string at line {line_number}") + if value[0] == "\"": + try: + return json.loads(value) + except json.JSONDecodeError as error: + raise PlanError(f"invalid YAML string at line {line_number}: {error.msg}") from error + return value[1:-1].replace("''", "'") + if value.startswith(("[", "{", "&", "*", "|", ">", "!")): + raise PlanError(f"unsupported YAML value at line {line_number}; use a scalar or indented mapping") + if re.fullmatch(r"[-+]?\d+", value): + return int(value) + if re.fullmatch(r"[-+]?(?:\d+\.\d*|\d*\.\d+)(?:[eE][-+]?\d+)?", value): + parsed = float(value) + if not isfinite(parsed): + raise PlanError(f"non-finite YAML number at line {line_number}") + return parsed + return value + + +def _load_yaml_mapping(payload: bytes) -> dict[str, Any]: + """Load strict, dependency-free mapping YAML for deterministic user settings.""" try: text = payload.decode("utf-8-sig") - parser = configparser.ConfigParser(interpolation=None, strict=True) - parser.read_string(text, source=str(path)) - except (UnicodeDecodeError, configparser.Error) as error: - raise PlanError(f"invalid legacy configuration file {path}: {error}") from error - - try: - runtime: dict[str, Any] = {} - logging: dict[str, Any] = {} - interaction: dict[str, Any] = {} - collectors: dict[str, dict[str, Any]] = {} - if parser.has_option("Settings", "max_workers"): - workers = parser.getint("Settings", "max_workers") - runtime.update({"default_max_workers": workers, "maximum_workers": workers}) - if parser.has_option("Settings", "log_using_debug"): - logging["level"] = "DEBUG" if parser.getboolean("Settings", "log_using_debug") else "INFO" - if parser.has_option("Settings", "delete_old_logs"): - logging["delete_previous"] = parser.getboolean("Settings", "delete_old_logs") - if parser.has_option("Settings", "save_preferences"): - interaction["history_enabled"] = parser.getboolean("Settings", "save_preferences") - - if parser.has_section("Flag Settings"): - if parser.has_option("Flag Settings", "accuracy_min"): - interaction["similarity_threshold"] = parser.getfloat("Flag Settings", "accuracy_min") / 100 - if parser.has_option("Flag Settings", "model_to_use"): - interaction["model_name"] = parser.get("Flag Settings", "model_to_use") - if parser.has_option("Flag Settings", "model_debug"): - interaction["model_debug"] = parser.getboolean("Flag Settings", "model_debug") - - if parser.has_section("DumpMemory Settings"): - limit_mib = parser.getint("DumpMemory Settings", "file_size_limit", fallback=0) - safety_factor = parser.getfloat("DumpMemory Settings", "file_size_safety", fallback=1.0) - if limit_mib < 0 or safety_factor < 1: - raise ValueError("memory limits require file_size_limit >= 0 and file_size_safety >= 1") - output_limit = min(64 * 1024 * 1024, limit_mib * 1024 * 1024) if limit_mib else 64 * 1024 * 1024 - collectors["core.process.memory_map"] = { - "output_limit_bytes": output_limit, - "disk_safety_margin_bytes": int(output_limit * (safety_factor - 1)), - "dump_directory": "memory_maps", - } - - if parser.has_section("NetWorkPsutil Settings"): - collectors["core.network.bandwidth_sample"] = { - "sample_count": parser.getint("NetWorkPsutil Settings", "sample_count", fallback=3), - "interval_seconds": parser.getfloat("NetWorkPsutil Settings", "interval", fallback=1.0), - } - - if parser.has_section("PacketSniffer Settings"): - collectors["core.packet.packet_capture"] = { - "interface": parser.get("PacketSniffer Settings", "interface", fallback="WiFi"), - "packet_count": parser.getint("PacketSniffer Settings", "packet_count", fallback=100), - "timeout_seconds": parser.getfloat("PacketSniffer Settings", "timeout", fallback=10), - "retry_window_seconds": parser.getfloat("PacketSniffer Settings", "max_retry_time", fallback=0), - } - except (configparser.Error, ValueError) as error: - raise PlanError(f"invalid legacy configuration file {path}: {error}") from error - return { - "schema_version": SCHEMA_VERSION, - "runtime": runtime, - "interaction": interaction, - "maintenance": {}, - "logging": logging, - "collectors": collectors, - } + except UnicodeDecodeError as error: + raise PlanError(f"invalid YAML configuration encoding: {error}") from error + if text.lstrip().startswith("{"): + try: + parsed = json.loads( + text, + object_pairs_hook=_unique_json_object, + parse_constant=_reject_json_constant, + parse_float=_finite_json_number, + ) + except ValueError as error: + raise PlanError(f"invalid YAML configuration: {error}") from error + if not isinstance(parsed, dict): + raise PlanError("YAML configuration root must be a mapping") + return parsed + records: list[tuple[int, int, str, str]] = [] + for number, raw_line in enumerate(text.splitlines(), start=1): + if "\t" in raw_line: + raise PlanError(f"invalid YAML indentation at line {number}; use spaces") + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + indent = len(line) - len(line.lstrip(" ")) + if indent % 2: + raise PlanError(f"invalid YAML indentation at line {number}; use multiples of two spaces") + content = line.strip() + if content.startswith("-") or ":" not in content: + raise PlanError(f"invalid YAML mapping entry at line {number}") + key, value = content.split(":", 1) + key = key.strip() + if not key or key.startswith(("\"", "'")) or any(character.isspace() for character in key): + raise PlanError(f"invalid YAML key at line {number}") + records.append((indent, number, key, value.strip())) + if not records: + raise PlanError("YAML configuration must contain a root mapping") + + def parse_mapping(index: int, indentation: int) -> tuple[dict[str, Any], int]: + """Parse one indentation-delimited YAML mapping level.""" + result: dict[str, Any] = {} + while index < len(records): + indent, number, key, value = records[index] + if indent < indentation: + break + if indent != indentation: + raise PlanError(f"invalid YAML nesting at line {number}") + if key in result: + raise PlanError(f"duplicate YAML key {key!r} at line {number}") + index += 1 + if not value and index < len(records) and records[index][0] > indentation: + result[key], index = parse_mapping(index, records[index][0]) + else: + result[key] = _yaml_scalar(value, line_number=number) + return result, index + + root, consumed = parse_mapping(0, 0) + if consumed != len(records): + raise PlanError("invalid YAML configuration nesting") + return root + + +def default_configuration_yaml() -> str: + """Return the YAML template written by installer and repair flows.""" + return """# Logicytics user configuration\nschema_version: 4\nruntime:\n output_root: output/data\n default_max_workers: 4\n maximum_workers: 16\n package_completed_runs: true\ninteraction:\n history_enabled: false\n similarity_threshold: 0.55\n model_name: stdlib-sequence-matcher\n model_debug: false\nmaintenance:\n local_manifest_path: project.manifest.json\n minimum_python: \"3.11\"\n recommended_python: \"3.11\"\n sysinternals_enabled: true\n sysinternals_download_url: https://download.sysinternals.com/files/SysinternalsSuite.zip\nlogging:\n level: INFO\n console_enabled: true\n color_enabled: true\n file_enabled: true\n maximum_bytes: 4194304\n delete_previous: false\n retention_days: 30\ncollectors: {}\n""" + + +def write_default_configuration(project_root: Path, *, overwrite: bool = False) -> Path: + """Atomically create the authoritative root YAML configuration template.""" + path = project_root / DEFAULT_CONFIGURATION_FILENAME + if path.exists() and not overwrite: + return path + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_text(default_configuration_yaml(), encoding="utf-8") + temporary.replace(path) + return path @dataclass(frozen=True, slots=True) @@ -329,6 +311,8 @@ class MaintenanceSettings: local_manifest_path: Path = Path("project.manifest.json") minimum_python: str = "3.11" recommended_python: str = "3.11" + sysinternals_enabled: bool = True + sysinternals_download_url: str = DEFAULT_SYSINTERNALS_DOWNLOAD_URL @dataclass(frozen=True, slots=True) @@ -346,7 +330,7 @@ class LoggingSettings: @dataclass(frozen=True, slots=True) class AppConfig: - """Validated settings loaded from an optional JSON configuration file.""" + """Validated settings loaded exclusively from the root YAML configuration file.""" schema_version: int runtime: RuntimeSettings @@ -383,47 +367,29 @@ def default_config(project_root: Path) -> AppConfig: def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: - """Load typed JSON, migrate historical CODE/config.ini, or use safe defaults.""" - modern_path = project_root / "logicytics.json" - legacy_path = project_root / "CODE" / "config.ini" - path = config_path or (modern_path if modern_path.exists() else legacy_path) + """Load and validate the single authoritative root YAML configuration file.""" + path = config_path or project_root / DEFAULT_CONFIGURATION_FILENAME if not path.is_absolute(): path = project_root / path if not path.exists(): return default_config(project_root) - legacy_ini = path.suffix.casefold() == ".ini" - if legacy_ini: - raw = _legacy_ini_payload(path) - else: - try: - payload = path.read_bytes() - except OSError as error: - raise PlanError(f"invalid configuration file {path}: {error}") from error - if len(payload) > MAXIMUM_CONFIGURATION_BYTES: - raise PlanError("configuration exceeds the 2 MiB limit") - try: - raw = json.loads( - payload.decode("utf-8-sig"), - object_pairs_hook=_unique_json_object, - parse_constant=_reject_json_constant, - parse_float=_finite_json_number, - ) - except (UnicodeDecodeError, ValueError) as error: - raise PlanError(f"invalid configuration file {path}: {error}") from error + if path.suffix.casefold() not in {".yaml", ".yml"}: + raise PlanError("configuration must be a YAML file") + try: + payload = path.read_bytes() + except OSError as error: + raise PlanError(f"invalid YAML configuration file {path}: {error}") from error + if len(payload) > MAXIMUM_CONFIGURATION_BYTES: + raise PlanError("YAML configuration exceeds the 2 MiB limit") + raw = _load_yaml_mapping(payload) if not isinstance(raw, dict): - raise PlanError("configuration root must be a JSON object") + raise PlanError("YAML configuration root must be a mapping") schema_version = raw.get("schema_version", SCHEMA_VERSION) if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise PlanError("configuration schema_version must be an integer") - migrated_from_schema: int | None = 3 if legacy_ini else None - if schema_version == 3: - raw = _migrate_v3_configuration(raw, project_root) - migrated_from_schema = schema_version - schema_version = SCHEMA_VERSION if schema_version != SCHEMA_VERSION: raise PlanError( - f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION} " - "or supported legacy schema 3" + f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION}" ) unknown_root = sorted(set(raw) - _ROOT_FIELDS) if unknown_root: @@ -533,6 +499,15 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi map(int, minimum_python.split(".")) ): raise PlanError("recommended_python must not be older than minimum_python") + sysinternals_enabled = maintenance_raw.get("sysinternals_enabled", True) + sysinternals_download_url = maintenance_raw.get( + "sysinternals_download_url", + DEFAULT_SYSINTERNALS_DOWNLOAD_URL, + ) + if not isinstance(sysinternals_enabled, bool): + raise PlanError("maintenance sysinternals_enabled must be boolean") + if not isinstance(sysinternals_download_url, str) or not sysinternals_download_url.startswith("https://"): + raise PlanError("maintenance sysinternals_download_url must be an HTTPS URL") logging_raw = raw.get("logging", {}) if not isinstance(logging_raw, dict): @@ -597,6 +572,8 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi local_manifest_path=local_manifest_path, minimum_python=minimum_python, recommended_python=recommended_python, + sysinternals_enabled=sysinternals_enabled, + sysinternals_download_url=sysinternals_download_url, ), logging=LoggingSettings( level=logging_level.upper(), @@ -608,5 +585,5 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi retention_days=retention_days, ), collector_settings=collector_settings, - migrated_from_schema=migrated_from_schema, + migrated_from_schema=None, ) diff --git a/logicytics/discovery.py b/logicytics/module/discovery.py similarity index 99% rename from logicytics/discovery.py rename to logicytics/module/discovery.py index 79cec911..f48875ee 100644 --- a/logicytics/discovery.py +++ b/logicytics/module/discovery.py @@ -786,12 +786,12 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: - engine_root = Path(__file__).resolve().parent.parent + engine_root = Path(__file__).resolve().parents[2] pythonpath = os.pathsep.join((str(engine_root), str(project_root))) command = [ sys.executable, "-m", - "logicytics.validation_worker", + "logicytics.module.validation_worker", str(candidate.path), candidate.kind.value, candidate.expected_class, diff --git a/logicytics/environment.py b/logicytics/module/environment.py similarity index 100% rename from logicytics/environment.py rename to logicytics/module/environment.py diff --git a/logicytics/errors.py b/logicytics/module/errors.py similarity index 100% rename from logicytics/errors.py rename to logicytics/module/errors.py diff --git a/logicytics/interaction.py b/logicytics/module/interaction.py similarity index 100% rename from logicytics/interaction.py rename to logicytics/module/interaction.py diff --git a/logicytics/logging.py b/logicytics/module/logging.py similarity index 85% rename from logicytics/logging.py rename to logicytics/module/logging.py index f0b5c018..c7f4c627 100644 --- a/logicytics/logging.py +++ b/logicytics/module/logging.py @@ -4,6 +4,7 @@ import json import sys +import textwrap import traceback from datetime import datetime, timezone from pathlib import Path @@ -11,9 +12,9 @@ from time import perf_counter, time from typing import Callable, Iterable, ParamSpec, TextIO, TypeVar -from logicytics.configuration import LoggingSettings +from logicytics.module.configuration import LoggingSettings from logicytics.contracts import EventLogger -from logicytics.redaction import redact_mapping, redact_text +from logicytics.module.redaction import redact_mapping, redact_text Parameters = ParamSpec("Parameters") Result = TypeVar("Result") @@ -81,6 +82,16 @@ def _truncate_file(self) -> None: retained = retained[newline + 1:] if newline >= 0 else retained self.path.write_bytes(b"[log truncated to configured maximum]\n" + retained) + @staticmethod + def _rows(level: str, source: str, message: str) -> tuple[str, ...]: + """Format aligned, deterministic human log rows for file inspection.""" + timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S") + source_column = source[:24] + wrapped = textwrap.wrap(message, width=76, break_long_words=False, break_on_hyphens=False) or [""] + first = f"{timestamp} | {level:<9} | {source_column:<24} | {wrapped[0]}" + continuation = f"{'':8} | {'':9} | {'':24} | " + return tuple([first, *(continuation + row for row in wrapped[1:])]) + def event(self, level: str, message: str, **fields: int | float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" normalized = level.upper() @@ -90,22 +101,24 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: return safe_message = redact_text(message) safe_fields = redact_mapping(fields) + source = str(safe_fields.pop("source", "logicytics.module")) suffix = "" if not safe_fields else " " + " ".join( f"{key}={json.dumps(value, ensure_ascii=True, sort_keys=True)}" for key, value in sorted(safe_fields.items()) ) - timestamp = datetime.now(timezone.utc).isoformat() - row = f"[{timestamp}] [{normalized}] {safe_message}{suffix}" + rows = self._rows(normalized, source, safe_message + suffix) with self._lock: if self.settings.file_enabled: with self.path.open("a", encoding="utf-8") as stream: - stream.write(row + "\n") + stream.write("\n".join(rows) + "\n") self._truncate_file() if self.settings.console_enabled: if self.settings.color_enabled and self.console.isatty(): - self.console.write(f"{_LEVEL_COLORS[normalized]}{row}\033[0m\n") + self.console.write(f"{_LEVEL_COLORS[normalized]}{rows[0]}\033[0m\n") + for row in rows[1:]: + self.console.write(row + "\n") else: - self.console.write(row + "\n") + self.console.write("\n".join(rows) + "\n") self.console.flush() def raw(self, message: str, *, end: str = "\n") -> None: @@ -126,6 +139,17 @@ def separator(self) -> None: """Write one blank line through the raw logging path.""" self.raw("") + def box(self, title: str, lines: Iterable[str]) -> None: + """Render non-log command output in a restrained grey ASCII panel.""" + content = tuple(redact_text(line) for line in lines) + width = min(100, max([len(title) + 4, *(len(line) + 2 for line in content)])) + border = "+" + "-" * width + "+" + rows = [border, f"| {title[:width - 2]:<{width - 2}} |", border] + rows.extend(f"| {line[:width - 2]:<{width - 2}} |" for line in content) + rows.append(border) + for row in rows: + self.raw(row) + def dispatch(self, messages: Iterable[str]) -> None: """Parse and dispatch a batch of optional `LEVEL: message` rows.""" for message in messages: diff --git a/logicytics/maintenance.py b/logicytics/module/maintenance.py similarity index 87% rename from logicytics/maintenance.py rename to logicytics/module/maintenance.py index b6bb4d6d..5e870a44 100644 --- a/logicytics/maintenance.py +++ b/logicytics/module/maintenance.py @@ -14,7 +14,7 @@ from pathlib import Path, PurePosixPath from typing import Any, Mapping, TypedDict -from logicytics.configuration import MaintenanceSettings +from logicytics.module.configuration import MaintenanceSettings _SHA256 = re.compile(r"^[0-9a-f]{64}$") _VERSION = re.compile( @@ -207,45 +207,6 @@ def write_local_manifest( return path -def write_legacy_ini_manifest( - project_root: Path, - manifest: IntegrityManifest, -) -> Path: - """Atomically update only version and file membership in historical CODE/config.ini.""" - if _VERSION.fullmatch(manifest.version) is None: - raise ValueError("next version must use semantic versioning") - path = (project_root / "CODE" / "config.ini").resolve() - try: - path.relative_to(project_root.resolve()) - payload = path.read_bytes() - except (ValueError, OSError) as error: - raise ValueError(f"legacy config.ini is unavailable: {error}") from error - if len(payload) > _MAXIMUM_MANIFEST_BYTES: - raise ValueError("legacy config.ini exceeds the 2 MiB limit") - try: - text = payload.decode("utf-8-sig") - except UnicodeDecodeError as error: - raise ValueError(f"legacy config.ini is not valid UTF-8: {error}") from error - header = re.search(r"(?m)^\[System Settings][ \t]*\r?$", text) - if header is None: - raise ValueError("legacy config.ini is missing [System Settings]") - following = re.search(r"(?m)^\[[^]\r\n]+][ \t]*\r?$", text[header.end():]) - section_end = header.end() + following.start() if following is not None else len(text) - replacement = text[header.start():section_end] - files = ", ".join(sorted(manifest.files)) - updates = {"version": manifest.version, "files": f'"{files}"'} - for key, value in updates.items(): - pattern = re.compile(rf"(?m)^{re.escape(key)}\s*=.*$") - if pattern.search(replacement) is None: - raise ValueError(f"legacy config.ini [System Settings] is missing {key}") - replacement = pattern.sub(f"{key} = {value}", replacement, count=1) - updated = text[:header.start()] + replacement + text[section_end:] - temporary = path.with_suffix(path.suffix + ".tmp") - temporary.write_text(updated, encoding="utf-8", newline="") - temporary.replace(path) - return path - - def compare_files( project_root: Path, settings: MaintenanceSettings, diff --git a/logicytics/manifest.py b/logicytics/module/manifest.py similarity index 99% rename from logicytics/manifest.py rename to logicytics/module/manifest.py index a2958022..5703c95a 100644 --- a/logicytics/manifest.py +++ b/logicytics/module/manifest.py @@ -13,7 +13,7 @@ from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus from logicytics.platform_adapters import windows_api_adapter -from logicytics.redaction import redact_mapping, redact_text +from logicytics.module.redaction import redact_mapping, redact_text MANIFEST_SCHEMA_VERSION = 1 diff --git a/logicytics/mod_runner.py b/logicytics/module/mod_runner.py similarity index 96% rename from logicytics/mod_runner.py rename to logicytics/module/mod_runner.py index 9f72652e..7ff05e31 100644 --- a/logicytics/mod_runner.py +++ b/logicytics/module/mod_runner.py @@ -28,7 +28,7 @@ def main(argv: list[str] | None = None) -> int: engine_root = Path(__file__).resolve().parent.parent sys.path.insert(0, str(engine_root)) from logicytics.contracts import Capability - from logicytics.runtime import _WorkerMutationGuard + from logicytics.module.runtime import _WorkerMutationGuard try: capabilities = tuple(Capability(value) for value in capability_values) diff --git a/logicytics/modes.py b/logicytics/module/modes.py similarity index 99% rename from logicytics/modes.py rename to logicytics/module/modes.py index fb66648b..56c8799c 100644 --- a/logicytics/modes.py +++ b/logicytics/module/modes.py @@ -8,7 +8,7 @@ from typing import Iterable, TypedDict, Mapping from logicytics.contracts import CollectorKind -from logicytics.discovery import CollectorCandidate +from logicytics.module.discovery import CollectorCandidate class ModeMatrixCollector(TypedDict): diff --git a/logicytics/output_contracts.py b/logicytics/module/output_contracts.py similarity index 100% rename from logicytics/output_contracts.py rename to logicytics/module/output_contracts.py diff --git a/logicytics/output_layout.py b/logicytics/module/output_layout.py similarity index 100% rename from logicytics/output_layout.py rename to logicytics/module/output_layout.py diff --git a/logicytics/packaging.py b/logicytics/module/packaging.py similarity index 99% rename from logicytics/packaging.py rename to logicytics/module/packaging.py index a91e310a..282c754f 100644 --- a/logicytics/packaging.py +++ b/logicytics/module/packaging.py @@ -11,12 +11,12 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, IO -from logicytics.artifacts import sha256_file +from logicytics.module.artifacts import sha256_file from logicytics.contracts import Artifact -from logicytics.manifest import RunManifest, write_manifest +from logicytics.module.manifest import RunManifest, write_manifest if TYPE_CHECKING: - from logicytics.runtime import RunOutcome + from logicytics.module.runtime import RunOutcome _STREAM_BLOCK_BYTES = 1024 * 1024 _MANIFEST_ARCHIVE_PATH = "metadata/manifest.json" diff --git a/logicytics/planner.py b/logicytics/module/planner.py similarity index 97% rename from logicytics/planner.py rename to logicytics/module/planner.py index 3d4bd657..7be5036d 100644 --- a/logicytics/planner.py +++ b/logicytics/module/planner.py @@ -10,9 +10,9 @@ from types import MappingProxyType from logicytics.contracts import Capability, CollectorKind, RunRequest -from logicytics.discovery import CollectorCandidate, PreflightReport -from logicytics.environment import inspect_environment -from logicytics.errors import PlanError, PreflightError +from logicytics.module.discovery import CollectorCandidate, PreflightReport +from logicytics.module.environment import inspect_environment +from logicytics.module.errors import PlanError, PreflightError BUILTIN_PROFILES = MappingProxyType({ "minimal": "Essential local system, memory, and storage inventory only.", diff --git a/logicytics/redaction.py b/logicytics/module/redaction.py similarity index 100% rename from logicytics/redaction.py rename to logicytics/module/redaction.py diff --git a/logicytics/runtime.py b/logicytics/module/runtime.py similarity index 98% rename from logicytics/runtime.py rename to logicytics/module/runtime.py index b7e3979c..e1239e01 100644 --- a/logicytics/runtime.py +++ b/logicytics/module/runtime.py @@ -25,9 +25,9 @@ from typing import Any, Mapping, TypedDict, cast, Callable, Protocol, TypeVar from uuid import uuid4 -from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.command_runner import parse_level_messages -from logicytics.configuration import AppConfig +from logicytics.module.artifacts import WorkspaceArtifactWriter +from logicytics.module.command_runner import parse_level_messages +from logicytics.module.configuration import AppConfig from logicytics.contracts import ( Artifact, Capability, @@ -41,14 +41,14 @@ RunStatus, ValidationResult, ) -from logicytics.discovery import CollectorCandidate -from logicytics.errors import LogicyticsError -from logicytics.logging import FileEventLogger, get_application_logger, get_event_logger -from logicytics.manifest import CollectorRecord, RunManifest, write_manifest, utc_now -from logicytics.output_contracts import core_output_contract -from logicytics.output_layout import ensure_output_layout -from logicytics.packaging import package_manifest -from logicytics.planner import RunPlan +from logicytics.module.discovery import CollectorCandidate +from logicytics.module.errors import LogicyticsError +from logicytics.module.logging import FileEventLogger, get_application_logger, get_event_logger +from logicytics.module.manifest import CollectorRecord, RunManifest, write_manifest, utc_now +from logicytics.module.output_contracts import core_output_contract +from logicytics.module.output_layout import ensure_output_layout +from logicytics.module.packaging import package_manifest +from logicytics.module.planner import RunPlan from logicytics.platform_adapters import process_adapter, windows_api_adapter _T = TypeVar("_T") diff --git a/logicytics/module/sysinternals.py b/logicytics/module/sysinternals.py new file mode 100644 index 00000000..12e67103 --- /dev/null +++ b/logicytics/module/sysinternals.py @@ -0,0 +1,71 @@ +"""Safe local Sysinternals archive discovery and extraction for v4.""" + +from __future__ import annotations + +import zipfile +from dataclasses import asdict, dataclass +from pathlib import Path +from tempfile import NamedTemporaryFile +from urllib.error import URLError +from urllib.request import urlopen + +from logicytics.module.configuration import MaintenanceSettings + + +@dataclass(frozen=True, slots=True) +class SysinternalsState: + """Configured download, archive, and extraction state for the local project.""" + + status: str + archive: Path + extraction_directory: Path + + def to_dict(self) -> dict[str, str]: + """Return a JSON-safe state summary.""" + data = asdict(self) + data["archive"] = str(self.archive) + data["extraction_directory"] = str(self.extraction_directory) + return data + + +def ensure_sysinternals( + project_root: Path, + settings: MaintenanceSettings | None = None, +) -> SysinternalsState: + """Honor YAML opt-out or securely download, validate, and extract Sysinternals.""" + settings = settings or MaintenanceSettings() + archive = project_root / "tools" / "SysinternalsSuite.zip" + extraction_directory = project_root / "tools" / "sysinternals" + if not settings.sysinternals_enabled: + return SysinternalsState("disabled", archive, extraction_directory) + if extraction_directory.is_dir(): + return SysinternalsState("extracted", archive, extraction_directory) + if not archive.is_file(): + archive.parent.mkdir(parents=True, exist_ok=True) + try: + with urlopen(settings.sysinternals_download_url, timeout=30) as response, NamedTemporaryFile( + mode="wb", dir=archive.parent, delete=False + ) as temporary: + temporary.write(response.read()) + downloaded = Path(temporary.name) + except (OSError, URLError) as error: + return SysinternalsState(f"download_failed: {error}", archive, extraction_directory) + try: + if not zipfile.is_zipfile(downloaded): + return SysinternalsState("download_failed: archive is not a ZIP file", archive, extraction_directory) + downloaded.replace(archive) + finally: + downloaded.unlink(missing_ok=True) + try: + with zipfile.ZipFile(archive) as bundle: + for member in bundle.infolist(): + destination = (extraction_directory / member.filename).resolve() + try: + destination.relative_to(extraction_directory.resolve()) + except ValueError as error: + raise ValueError("Sysinternals archive contains an unsafe path") from error + extraction_directory.mkdir(parents=True, exist_ok=True) + bundle.extractall(extraction_directory) + except (OSError, ValueError, zipfile.BadZipFile) as error: + return SysinternalsState(f"extract_failed: {error}", archive, extraction_directory) + return SysinternalsState("extracted", archive, extraction_directory) diff --git a/logicytics/validation_worker.py b/logicytics/module/validation_worker.py similarity index 100% rename from logicytics/validation_worker.py rename to logicytics/module/validation_worker.py diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index b2b575aa..8bffc45f 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -3,6 +3,7 @@ from __future__ import annotations import ctypes +import importlib import os import shutil import signal @@ -15,7 +16,9 @@ from pathlib import Path from typing import Any -from logicytics.ctypes_collector import query_registry_key_info, filetime +_ctypes_collector = importlib.import_module("logicytics.global.ctypes_collector") +query_registry_key_info = _ctypes_collector.query_registry_key_info +filetime = _ctypes_collector.filetime try: import winreg as _winreg @@ -44,13 +47,13 @@ def _command( return normalized + @staticmethod def run( - self, command: Sequence[str | os.PathLike[str]], **options: Any, ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" - normalized = self._command(command) + normalized = ProcessAdapter._command(command) capture_directory = options.pop("capture_directory", None) if options.get("shell"): @@ -104,12 +107,12 @@ def run( stderr_size = stderr.tell() if ( - stdout_size > self.maximum_capture_bytes - or stderr_size > self.maximum_capture_bytes + stdout_size > ProcessAdapter.maximum_capture_bytes + or stderr_size > ProcessAdapter.maximum_capture_bytes ): raise ValueError( f"command output exceeds the " - f"{self.maximum_capture_bytes}-byte capture limit" + f"{ProcessAdapter.maximum_capture_bytes}-byte capture limit" ) stdout.seek(0) diff --git a/logicytics/sysinternals.py b/logicytics/sysinternals.py deleted file mode 100644 index 8bd85aa4..00000000 --- a/logicytics/sysinternals.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Safe local Sysinternals archive discovery and extraction for v4.""" - -from __future__ import annotations - -import zipfile -from dataclasses import asdict, dataclass -from pathlib import Path - - -@dataclass(frozen=True, slots=True) -class SysinternalsState: - """Archive, ignore-marker, and extraction state for the local project.""" - - status: str - archive: Path - extraction_directory: Path - - def to_dict(self) -> dict[str, str]: - """Return a JSON-safe state summary.""" - data = asdict(self) - data["archive"] = str(self.archive) - data["extraction_directory"] = str(self.extraction_directory) - return data - - -def ensure_sysinternals(project_root: Path) -> SysinternalsState: - """Safely extract a bundled archive unless an explicit local marker opts out.""" - archive = project_root / "SysinternalsSuite.zip" - extraction_directory = project_root / "tools" / "sysinternals" - if (project_root / ".ignore-sysinternals").is_file(): - return SysinternalsState("ignored", archive, extraction_directory) - if extraction_directory.is_dir(): - return SysinternalsState("extracted", archive, extraction_directory) - if not archive.is_file(): - return SysinternalsState("missing", archive, extraction_directory) - extraction_directory.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(archive) as bundle: - for member in bundle.infolist(): - destination = (extraction_directory / member.filename).resolve() - try: - destination.relative_to(extraction_directory.resolve()) - except ValueError as error: - raise ValueError("Sysinternals archive contains an unsafe path") from error - bundle.extractall(extraction_directory) - return SysinternalsState("extracted", archive, extraction_directory) diff --git a/tests/test_artifact_writer.py b/tests/test_artifact_writer.py index ff327ffa..a3b15aa4 100644 --- a/tests/test_artifact_writer.py +++ b/tests/test_artifact_writer.py @@ -7,18 +7,18 @@ from pathlib import Path from unittest.mock import patch -from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.configuration import ( +from logicytics.module.artifacts import WorkspaceArtifactWriter +from logicytics.module.configuration import ( default_config, ) from logicytics.contracts import ( EvidenceKind, RunRequest, ) -from logicytics.discovery import preflight -from logicytics.errors import ArtifactError -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.errors import ArtifactError +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR diff --git a/tests/test_cli.py b/tests/test_cli.py index dae9ddff..ca957703 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch from logicytics.cli import CLI, cli_methods, main -from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix +from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.platform_adapters import process_adapter diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 24121733..c426e557 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -7,17 +7,17 @@ from pathlib import Path from typing import Any, cast -from logicytics.configuration import ( +from logicytics.module.configuration import ( default_config, load_config, ) from logicytics.contracts import ( RunRequest, ) -from logicytics.discovery import preflight -from logicytics.errors import PlanError -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.errors import PlanError +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR @@ -28,7 +28,7 @@ def test_configuration_schema_version_is_enforced(self) -> None: """Current v4 and explicitly migrated v3 schemas are accepted; other versions are not.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" for unsupported in (0, 1, 2, 5): with self.subTest(schema_version=unsupported): config_path.write_text(json.dumps({"schema_version": unsupported}), encoding="utf-8") @@ -50,7 +50,7 @@ def test_legacy_configuration_migrates_runtime_and_collector_aliases_without_wri """A supported v3 configuration migrates once in memory and preserves its source bytes.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" settings = {"core.packet.packet_capture": {"packet_count": 7, "timeout_seconds": 5}} original = json.dumps({ "schema_version": 3, @@ -131,7 +131,7 @@ def test_historical_code_config_ini_migrates_into_typed_v4_settings(self) -> Non self.assertEqual(5000, configuration.settings_for("core.packet.packet_capture")["packet_count"]) self.assertEqual(original, config_path.read_text(encoding="utf-8")) - (root / "logicytics.json").write_text('{"schema_version":4}', encoding="utf-8") + (root / "logicytics.yaml").write_text('{"schema_version":4}', encoding="utf-8") self.assertIsNone(load_config(root).migrated_from_schema) self.assertFalse((root / "output").exists()) self.assertEqual(3, configuration.to_manifest_dict()["migrated_from_schema"]) @@ -140,7 +140,7 @@ def test_legacy_configuration_rejects_ambiguous_unsafe_and_plugin_enabling_migra """Migration cannot override settings, weaken validation, or silently enable extensions.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" invalid = ( ({"workers": 2, "worker_count": 3}, "conflicting settings"), ({"workers": 2, "runtime": {"default_max_workers": 2}}, "conflicting settings"), @@ -172,7 +172,7 @@ def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evi legacy.parent.mkdir(parents=True) legacy.write_text("keep legacy evidence", encoding="utf-8") - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" original = json.dumps( { "schema_version": 3, @@ -214,7 +214,7 @@ def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: expected = root / "output" / "data" self.assertEqual(expected, default_config(root).runtime.output_root) self.assertEqual(expected, load_config(root).runtime.output_root) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" config_path.write_text('{"schema_version":4,"runtime":{}}', encoding="utf-8") self.assertEqual(expected, load_config(root).runtime.output_root) config_path.write_text( @@ -235,7 +235,7 @@ def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_s "private_key": "private-value", "ordinary": "safe", } - (root / "logicytics.json").write_text( + (root / "logicytics.yaml").write_text( json.dumps({"schema_version": 4, "collectors": {collector_id: settings}}), encoding="utf-8", ) @@ -256,7 +256,7 @@ def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> """Runtime worker limits and output locations must retain strict JSON types.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" invalid_configurations = ( ('{"schema_version":true}', "schema_version"), ('{"schema_version":4,"runtime":{"default_max_workers":true}}', "worker limits"), @@ -276,7 +276,7 @@ def test_configuration_rejects_unknown_duplicate_and_non_finite_values(self) -> """Ambiguous keys, typos, malformed IDs, and nonstandard JSON fail before planning.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" invalid = ( ('{"schema_version":4,"unexpected":true}', "unsupported root"), ('{"schema_version":4,"runtime":{"worker_typo":2}}', "unsupported settings"), @@ -305,7 +305,7 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) """Traversal and sensitive inventory limits cannot silently coerce or expand in workers.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" invalid = ( ("core.filesystem.system_drive_tree", {"max_entries": True}, "max_entries"), ("core.filesystem.system_drive_tree", {"max_depth": 33}, "max_depth"), @@ -340,7 +340,7 @@ def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_p """The existing metadata-only memory mapper rejects unsafe values before worker launch.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" collector_id = "core.process.memory_map" invalid = ( ({"max_regions": True}, "max_regions"), @@ -384,7 +384,7 @@ def test_configuration_profile_collector_and_request_boundaries_are_isolated(sel "disk_safety_margin_bytes": 4096, "dump_directory": "maps", } - (root / "logicytics.json").write_text( + (root / "logicytics.yaml").write_text( json.dumps({ "schema_version": 4, "runtime": {"default_max_workers": 3, "maximum_workers": 8}, @@ -412,7 +412,7 @@ def test_configuration_validates_bounded_network_and_packet_settings(self) -> No """Collector-specific settings fail early rather than being silently coerced at runtime.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" config_path.write_text( '{"schema_version":4,"collectors":{"core.network.bandwidth_sample":' '{"sample_count":11}}}', diff --git a/tests/test_configuration_documentation.py b/tests/test_configuration_documentation.py index b2028762..6d757314 100644 --- a/tests/test_configuration_documentation.py +++ b/tests/test_configuration_documentation.py @@ -8,8 +8,8 @@ import unittest from pathlib import Path -from logicytics import configuration -from logicytics.errors import PlanError +from logicytics.module import configuration +from logicytics.module.errors import PlanError class ConfigurationDocumentationTests(unittest.TestCase): @@ -46,7 +46,7 @@ def test_complete_example_loads_through_the_real_parser(self) -> None: payload = json.loads(match.group(1)) with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - path = root / "logicytics.json" + path = root / "logicytics.yaml" path.write_text(json.dumps(payload), encoding="utf-8") loaded = configuration.load_config(root) self.assertEqual(configuration.SCHEMA_VERSION, loaded.schema_version) @@ -75,7 +75,7 @@ def test_modern_json_is_bounded_and_relative_explicit_paths_use_the_project(self def test_core_settings_require_an_explicit_schema_while_extensions_remain_open(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - path = root / "logicytics.json" + path = root / "logicytics.yaml" path.write_text( json.dumps({ "schema_version": 4, diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index ceb6e4fb..4804ca72 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -16,7 +16,7 @@ from logicytics.contracts import ( Artifact, ArtifactWriter, CollectorContext, EvidenceKind, EventLogger, RunStatus, ) -from logicytics.manifest import CollectorRecord, RunManifest +from logicytics.module.manifest import CollectorRecord, RunManifest _GOLDEN = Path(__file__).parent / "golden" diff --git a/tests/test_interaction.py b/tests/test_interaction.py index dc2d0945..8ed3550f 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -8,11 +8,11 @@ from unittest.mock import patch from logicytics.cli import CLI, main -from logicytics.configuration import ( +from logicytics.module.configuration import ( load_config, ) -from logicytics.errors import PlanError -from logicytics.interaction import load_history, match_flag, usage_statistics +from logicytics.module.errors import PlanError +from logicytics.module.interaction import load_history, match_flag, usage_statistics class InteractionTests(unittest.TestCase): @@ -33,7 +33,7 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel self.assertFalse(payload["history_persisted"]) self.assertFalse((root / "output" / "data" / "interaction_history.json.gz").exists()) - (root / "logicytics.json").write_text( + (root / "logicytics.yaml").write_text( json.dumps({ "schema_version": 4, "interaction": { @@ -99,7 +99,7 @@ def test_interaction_configuration_rejects_unsafe_or_ambiguous_values(self) -> N for section in invalid_sections: with self.subTest(section=section), tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - (root / "logicytics.json").write_text( + (root / "logicytics.yaml").write_text( json.dumps({"schema_version": 4, "interaction": section}), encoding="utf-8", ) diff --git a/tests/test_legacy_code_outputs_collector.py b/tests/test_legacy_code_outputs_collector.py index d35a0f83..9eb4cc3c 100644 --- a/tests/test_legacy_code_outputs_collector.py +++ b/tests/test_legacy_code_outputs_collector.py @@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch from core.integration.legacy_code_outputs import LegacyCodeOutputsCollector -from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/tests/test_logging.py b/tests/test_logging.py index cbd42cdf..88950e8a 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -9,13 +9,13 @@ from pathlib import Path from typing import Any, cast -from logicytics.command_runner import parse_level_messages, run_command -from logicytics.configuration import ( +from logicytics.module.command_runner import parse_level_messages, run_command +from logicytics.module.configuration import ( LoggingSettings, load_config, ) -from logicytics.errors import PlanError -from logicytics.logging import ( +from logicytics.module.errors import PlanError +from logicytics.module.logging import ( ApplicationLogger, FileEventLogger, deprecated, @@ -24,7 +24,7 @@ raise_logged, timed, ) -from logicytics.output_layout import ensure_output_layout +from logicytics.module.output_layout import ensure_output_layout from tests.fixtures.file_listing import list_files @@ -72,7 +72,7 @@ def isatty(self) -> bool: self.assertLessEqual(path.stat().st_size, settings.maximum_bytes) self.assertFalse(old.exists()) self.assertIn("\033[", console.getvalue()) - self.assertIn("[EXCEPTION]", console.getvalue()) + self.assertIn("| EXCEPTION", console.getvalue()) with self.assertRaisesRegex(ValueError, "unsupported log level"): logger.event("TRACE", "unsupported") with self.assertRaisesRegex(ValueError, "raw log end"): @@ -115,7 +115,7 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self layout.hashes, ): self.assertTrue(directory.is_dir()) - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" config_path.write_text( json.dumps({ "schema_version": 4, diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index bc594704..4db62835 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -9,16 +9,16 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from logicytics import ( +from logicytics.module import ( maintenance, ) from logicytics.cli import CLI, main -from logicytics.configuration import ( +from logicytics.module.configuration import ( MaintenanceSettings, load_config, ) -from logicytics.errors import PlanError -from logicytics.maintenance import ( +from logicytics.module.errors import PlanError +from logicytics.module.maintenance import ( build_manifest, compare_files, compare_versions, @@ -26,7 +26,7 @@ project_files, write_local_manifest, ) -from logicytics.sysinternals import ensure_sysinternals +from logicytics.module.sysinternals import ensure_sysinternals class MaintenanceTests(unittest.TestCase): @@ -38,7 +38,7 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( """Integrity endpoints and Python policy fail closed during configuration loading.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config = root / "logicytics.json" + config = root / "logicytics.yaml" valid = { "schema_version": 4, "maintenance": { diff --git a/tests/test_mods.py b/tests/test_mods.py index 9a25f8e4..f13d175e 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -9,12 +9,12 @@ from pathlib import Path from logicytics.cli import cli_methods -from logicytics.configuration import default_config +from logicytics.module.configuration import default_config from logicytics.contracts import Capability, RunRequest -from logicytics.discovery import preflight -from logicytics.errors import PlanError, PreflightError -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.errors import PlanError, PreflightError +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import mod_metadata @@ -140,7 +140,7 @@ def test_python_mod_cannot_mutate_project_configuration_without_write_approval( """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - configuration_path = root / "logicytics.json" + configuration_path = root / "logicytics.yaml" configuration_path.write_text( '{"schema_version":4}\n', diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 6707eeb5..4c3e4695 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -12,20 +12,20 @@ from typing import Any, Callable, cast from unittest.mock import patch -from logicytics import packaging +from logicytics.module import packaging from logicytics.cli import cli_methods -from logicytics.configuration import ( +from logicytics.module.configuration import ( default_config, load_config, ) from logicytics.contracts import ( RunRequest, ) -from logicytics.discovery import preflight -from logicytics.manifest import write_manifest -from logicytics.packaging import package_run -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.manifest import write_manifest +from logicytics.module.packaging import package_run +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR, delayed_collector_source @@ -52,7 +52,7 @@ def capture_manifest_state(path, manifest): persisted_states.append(manifest.status.value) write_manifest(path, manifest) - with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest_state): + with patch("logicytics.module.runtime.write_manifest", side_effect=capture_manifest_state): outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual("planned", persisted_states[0]) self.assertEqual("running", persisted_states[1]) @@ -236,7 +236,7 @@ def test_configured_output_root_colocates_packages_without_touching_legacy_evide legacy_evidence = root / "ACCESS" / "RUNS" / "legacy-evidence.txt" legacy_evidence.parent.mkdir(parents=True) legacy_evidence.write_text("preserve existing evidence", encoding="utf-8") - (root / "logicytics.json").write_text( + (root / "logicytics.yaml").write_text( '{"schema_version":4,"runtime":{"output_root":"custom/evidence"}}', encoding="utf-8", ) @@ -392,7 +392,7 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r ), encoding="utf-8", ) - (root / "logicytics.json").write_text( + (root / "logicytics.yaml").write_text( json.dumps( { "schema_version": 4, @@ -540,13 +540,13 @@ def test_collector_resource_progress_persists_in_manifest_summary_and_performanc RunRequest(max_workers=1, acknowledge_authorization=True, performance_check=True), ) snapshots: list[dict[str, Any]] = [] - from logicytics.manifest import write_manifest as original_write_manifest + from logicytics.module.manifest import write_manifest as original_write_manifest def capture_manifest(path: Path, manifest: Any) -> None: snapshots.append(json.loads(json.dumps(manifest.to_dict()))) original_write_manifest(path, manifest) - with patch("logicytics.runtime.write_manifest", side_effect=capture_manifest): + with patch("logicytics.module.runtime.write_manifest", side_effect=capture_manifest): outcome = RunSupervisor(root, default_config(root)).run(plan) record = outcome.manifest.collectors[0] expected = { @@ -663,7 +663,7 @@ def fail_sidecar(source: str | Path, destination: str | Path) -> None: raise OSError("simulated sidecar publication failure") original_replace(source, destination) - with patch("logicytics.packaging.os.replace", side_effect=fail_sidecar): + with patch("logicytics.module.packaging.os.replace", side_effect=fail_sidecar): with self.assertRaisesRegex(OSError, "sidecar publication failure"): package_run(outcome) self.assertEqual(original_package, package_path.read_bytes()) diff --git a/tests/test_planning.py b/tests/test_planning.py index eb6a2b92..6eef9504 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -9,7 +9,7 @@ from unittest.mock import patch from logicytics.cli import CLI, cli_methods, main -from logicytics.configuration import ( +from logicytics.module.configuration import ( default_config, load_config, ) @@ -20,15 +20,15 @@ RunRequest, RunStatus, ) -from logicytics.discovery import PreflightReport, preflight -from logicytics.environment import EnvironmentReport -from logicytics.errors import PlanError -from logicytics.logging import ( +from logicytics.module.discovery import PreflightReport, preflight +from logicytics.module.environment import EnvironmentReport +from logicytics.module.errors import PlanError +from logicytics.module.logging import ( FileEventLogger, ) -from logicytics.planner import BUILTIN_PROFILES, build_plan +from logicytics.module.planner import BUILTIN_PROFILES, build_plan from logicytics.platform_adapters import ProcessAdapter -from logicytics.runtime import RunSupervisor +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR, delayed_collector_source @@ -300,7 +300,7 @@ def test_configured_worker_limit_blocks_oversized_run_before_workspace_creation( collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text(COLLECTOR, encoding="utf-8") - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" config_path.write_text( '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', encoding="utf-8", @@ -576,11 +576,11 @@ def test_elevated_collectors_require_approval_and_administrator_privileges(self) for administrator_state in (False, None): with self.subTest(administrator_state=administrator_state): environment = EnvironmentReport(administrator_state, True, None) - with patch("logicytics.planner.inspect_environment", return_value=environment): + with patch("logicytics.module.planner.inspect_environment", return_value=environment): with self.assertRaisesRegex(PlanError, "administrator account"): build_plan(report, approved) with patch( - "logicytics.planner.inspect_environment", + "logicytics.module.planner.inspect_environment", return_value=EnvironmentReport(True, True, None), ): plan = build_plan(report, approved) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index dd311c24..63b50274 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -12,10 +12,10 @@ from unittest.mock import Mock, patch from core.packet import packet_capture -from logicytics import environment -from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.module import environment +from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.environment import inspect_environment +from logicytics.module.environment import inspect_environment from logicytics.platform_adapters import ( FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, which, windows_api_adapter, registry_adapter, diff --git a/tests/test_preflight.py b/tests/test_preflight.py index ccc2acf9..93ec7fa8 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -8,23 +8,23 @@ from pathlib import Path from unittest.mock import patch -from logicytics import ( +from logicytics.module import ( discovery, ) from logicytics.cli import CLI, main -from logicytics.configuration import ( +from logicytics.module.configuration import ( load_config, ) from logicytics.contracts import ( Capability, RunRequest, ) -from logicytics.discovery import preflight -from logicytics.environment import EnvironmentReport -from logicytics.errors import PlanError, PreflightError -from logicytics.planner import build_plan +from logicytics.module.discovery import preflight +from logicytics.module.environment import EnvironmentReport +from logicytics.module.errors import PlanError, PreflightError +from logicytics.module.planner import build_plan from logicytics.platform_adapters import ProcessAdapter -from logicytics.runtime import RunSupervisor +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR, plugin_collector_source, delayed_collector_source @@ -171,7 +171,7 @@ def test_run_output_budget_bounds_concurrent_collector_evidence(self) -> None: ' supported_platforms=("win32",),\n maximum_output_bytes=4,', ) (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" config_path.write_text( '{"schema_version":4,"runtime":{"maximum_run_output_bytes":7}}', encoding="utf-8", @@ -203,7 +203,7 @@ def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: ' supported_platforms=("win32",),\n maximum_output_bytes=4,', ) (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") - config_path = root / "logicytics.json" + config_path = root / "logicytics.yaml" config_path.write_text( '{"schema_version":4,"runtime":{"maximum_run_output_bytes":4}}', encoding="utf-8", @@ -660,7 +660,7 @@ def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> Non plugin_path.write_text(source, encoding="utf-8") report = preflight(root) self.assertEqual((), report.invalid) - with patch("logicytics.planner.inspect_environment") as inspect: + with patch("logicytics.module.planner.inspect_environment") as inspect: plan = build_plan(report, RunRequest()) self.assertEqual((), plan.collectors) inspect.assert_not_called() @@ -669,7 +669,7 @@ def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> Non approved_capabilities=(Capability.ELEVATED_PRIVILEGES,), ) with patch( - "logicytics.planner.inspect_environment", + "logicytics.module.planner.inspect_environment", return_value=EnvironmentReport(False, True, None), ): with self.assertRaisesRegex(PlanError, "administrator account"): diff --git a/tests/test_public_api.py b/tests/test_public_api.py index fddc0b0b..2a8f6dd1 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -25,8 +25,8 @@ RunRequest, RunStatus, ) -from logicytics.discovery import CollectorCandidate -from logicytics.errors import ArtifactError, PlanError, PreflightError +from logicytics.module.discovery import CollectorCandidate +from logicytics.module.errors import ArtifactError, PlanError, PreflightError from tests.fixtures.collectors import COLLECTOR, plugin_collector_source @@ -44,7 +44,7 @@ def test_public_package_import_does_not_start_runtime_or_create_files(self) -> N "import pathlib, sys; " "before = tuple(pathlib.Path.cwd().iterdir()); " "import logicytics; " - "assert 'logicytics.runtime' not in sys.modules; " + "assert 'logicytics.module.runtime' not in sys.modules; " "assert {'load_configuration', 'plan_run', 'run_collection', 'query_run', 'read_artifact'}" ".issubset(logicytics.__all__); " "assert tuple(pathlib.Path.cwd().iterdir()) == before" @@ -404,7 +404,7 @@ def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_ac original = stored.read_bytes() manifest_source = outcome.manifest_path.read_text(encoding="utf-8") - with patch("logicytics.api.os.startfile") as startfile: + with patch("logicytics.module.api.os.startfile") as startfile: self.assertEqual( stored, open_artifact(root, outcome.manifest.run_id, artifact.id), diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py index 34945ee6..a34e1400 100644 --- a/tests/test_release_documentation.py +++ b/tests/test_release_documentation.py @@ -8,8 +8,8 @@ from pathlib import Path from logicytics.contracts import Specialty -from logicytics.discovery import preflight -from logicytics.modes import EXECUTION_MODES +from logicytics.module.discovery import preflight +from logicytics.module.modes import EXECUTION_MODES class ReleaseDocumentationTests(unittest.TestCase): diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 756b0037..1aae5ae2 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest.mock import patch -from logicytics.configuration import ( +from logicytics.module.configuration import ( default_config, ) from logicytics.contracts import ( @@ -21,9 +21,9 @@ STILL_ACTIVE, close_handle ) -from logicytics.discovery import preflight -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR, delayed_collector_source diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index e93c6326..7fa29807 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -5,16 +5,16 @@ from pathlib import Path from logicytics.cli import cli_methods -from logicytics.configuration import ( +from logicytics.module.configuration import ( default_config, ) from logicytics.contracts import ( ResourceClass, RunRequest, ) -from logicytics.discovery import preflight -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import delayed_collector_source diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 42b8126e..4da681ad 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -13,7 +13,7 @@ from unittest.mock import patch from logicytics import ResourceClass -from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import ( ArtifactWriter, CollectorContext, @@ -21,9 +21,9 @@ EventLogger, EvidenceKind, ) -from logicytics.discovery import preflight -from logicytics.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix -from logicytics.output_contracts import core_output_contract +from logicytics.module.discovery import preflight +from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix +from logicytics.module.output_contracts import core_output_contract from logicytics.platform_adapters import ( filesystem_adapter, network_adapter, diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py index 1dd3ad4d..442b4732 100644 --- a/tests/test_windows_integration.py +++ b/tests/test_windows_integration.py @@ -7,9 +7,9 @@ import unittest from pathlib import Path -from logicytics.artifacts import WorkspaceArtifactWriter -from logicytics.discovery import preflight -from logicytics.output_contracts import core_output_contract +from logicytics.module.artifacts import WorkspaceArtifactWriter +from logicytics.module.discovery import preflight +from logicytics.module.output_contracts import core_output_contract from logicytics.platform_adapters import ( process_adapter, registry_adapter, which, windows_api_adapter, ) diff --git a/tests/test_wmic_inventory_collector.py b/tests/test_wmic_inventory_collector.py index 54fdd842..3cc0de38 100644 --- a/tests/test_wmic_inventory_collector.py +++ b/tests/test_wmic_inventory_collector.py @@ -9,7 +9,7 @@ from unittest.mock import Mock, patch from core.system import wmic_inventory -from logicytics.artifacts import WorkspaceArtifactWriter +from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index 9e4e5e5f..1fd3703a 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -5,16 +5,16 @@ import unittest from pathlib import Path -from logicytics.configuration import ( +from logicytics.module.configuration import ( default_config, ) from logicytics.contracts import ( Capability, RunRequest, ) -from logicytics.discovery import preflight -from logicytics.planner import build_plan -from logicytics.runtime import RunSupervisor +from logicytics.module.discovery import preflight +from logicytics.module.planner import build_plan +from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR, delayed_collector_source @@ -303,7 +303,7 @@ def test_worker_blocks_application_update_power_and_peer_collector_commands_with (root / "plugins").mkdir() peer_path = core_directory / "z_independent.py" peer_path.write_text(delayed_collector_source("z_independent", 0.0), encoding="utf-8") - protected_configuration = root / "logicytics.json" + protected_configuration = root / "logicytics.yaml" attempts = ( ([sys.executable, "-m", "logicytics", "preflight"], "main_application"), ([sys.executable, "-m", "pip", "--version"], "repository_or_package_management"), @@ -369,8 +369,8 @@ def test_preflight_rejects_collector_imports_of_application_and_orchestration_se "from logicytics import run_collection", "from logicytics import packaging", "import logicytics\nlogicytics.run_collection", - "import logicytics\nlogicytics.configuration", - "from logicytics.api import query_run", + "import logicytics\nlogicytics.module.configuration", + "from logicytics.module.api import query_run", ) for statement in attempts: with self.subTest(statement=statement): From b1ebc337219c96f6b0f2bcaa5e60ff329cd91c9c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 16:12:40 +0300 Subject: [PATCH 242/316] docs: complete simple source documentation pass --- core/packet/connection_graph.py | 1 + core/packet/packet_capture.py | 1 + core/process/memory_map.py | 1 + logicytics/cli/commands.py | 3 +++ logicytics/contracts.py | 3 +++ logicytics/module/artifacts.py | 5 +++++ logicytics/module/discovery.py | 11 +++++++++++ logicytics/module/interaction.py | 2 ++ logicytics/module/logging.py | 6 ++++++ logicytics/module/maintenance.py | 2 ++ logicytics/module/modes.py | 3 +++ logicytics/module/packaging.py | 1 + logicytics/module/planner.py | 3 +++ logicytics/module/redaction.py | 1 + logicytics/module/runtime.py | 12 ++++++++++++ logicytics/module/validation_worker.py | 6 ++++++ logicytics/platform_adapters.py | 24 ++++++++++++++++++++++++ 17 files changed, 85 insertions(+) diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 879a761f..43ff200d 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -27,6 +27,7 @@ def render_connection_graph(command_output: str) -> str: edges.add((source, destination, protocol.upper())) def quote(value: str) -> str: + """Escape one label for safe inclusion in a quoted DOT string.""" return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' lines = ["digraph connection_graph {", " rankdir=LR;"] diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index 239fd99c..e4ab35ec 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -78,6 +78,7 @@ def validate(self, context: CollectorContext) -> ValidationResult: @staticmethod def _close_capture(capture_socket: socket.socket) -> None: + """Disable Windows promiscuous capture mode and close the socket.""" try: capture_socket.ioctl( socket.SIO_RCVALL, diff --git a/core/process/memory_map.py b/core/process/memory_map.py index a40d06b1..358b5581 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -43,6 +43,7 @@ def validate(self, context: CollectorContext) -> ValidationResult: ) def setting_int(name: str, default: int) -> int: + """Read one integer setting while rejecting booleans and unsupported objects.""" value = context.settings.get(name, default) if isinstance(value, bool): diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 90753e2c..af5d7b9d 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -44,6 +44,7 @@ class CLI: + """Translate command-line arguments into validated application requests.""" @staticmethod def render_preflight( logger: object, @@ -84,6 +85,7 @@ def project_root() -> Path: @staticmethod def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: + """Build an immutable run request while enforcing mode and rerun conflicts.""" legacy_flags = { flag: getattr(arguments, flag, False) for flag in LEGACY_MODE_ALIASES @@ -287,6 +289,7 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: @staticmethod def parser() -> argparse.ArgumentParser: + """Create the complete parser for maintenance, planning, and collection commands.""" parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument( "--config", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 1230f3db..33738a34 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -371,6 +371,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> "Artifact": return cls(**values) def to_dict(self) -> dict[str, Any]: + """Return the normalized artifact as a JSON-safe mapping.""" return asdict(self) @@ -436,6 +437,7 @@ def __post_init__(self) -> None: @classmethod def succeeded(cls, summary: str, artifacts: tuple[Artifact, ...] = ()) -> "CollectorResult": + """Return a successful result containing any registered evidence.""" return cls(CollectorStatus.SUCCEEDED, summary, artifacts) @classmethod @@ -564,6 +566,7 @@ def __init__( settings: Mapping[str, object], cancellation_file: Path, ) -> None: + """Initialize the limited worker context and its cancellation boundary.""" self.run_id = run_id self.collector_id = collector_id self.workspace = workspace diff --git a/logicytics/module/artifacts.py b/logicytics/module/artifacts.py index 36e5fef6..7e1db68c 100644 --- a/logicytics/module/artifacts.py +++ b/logicytics/module/artifacts.py @@ -17,6 +17,7 @@ def _is_within(path: Path, parent: Path) -> bool: + """Return whether a resolved path remains below its owning root.""" try: path.resolve().relative_to(parent.resolve()) except ValueError: @@ -51,6 +52,7 @@ def __init__( allowed_relative_paths: tuple[str, ...] | None = None, allowed_media_types: tuple[str, ...] | None = None, ) -> None: + """Initialize a workspace-bound writer with collector and run output quotas.""" self._collector_id = collector_id collector_parts = collector_id.split(".", 2) resolved_category = source_category if source_category is not None else ( @@ -116,6 +118,7 @@ def _register_file( evidence_kind: EvidenceKind, transformations: tuple[str, ...], ) -> Artifact: + """Copy one validated source into the run artifact tree and catalog it.""" if not isinstance(media_type, str) or not re.fullmatch( r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*", media_type, @@ -175,10 +178,12 @@ def _register_file( return artifact def _check_cancellation(self) -> None: + """Raise when the run cancellation marker requests cooperative shutdown.""" if self._cancellation_file is not None and self._cancellation_file.exists(): raise ArtifactError("artifact registration was cancelled") def _check_output_limits(self, size_bytes: int) -> None: + """Reject publication that would exceed collector or run output budgets.""" if size_bytes > self._maximum_artifact_bytes: raise ArtifactError("collector artifact exceeds its declared maximum_artifact_bytes") if self._bytes_registered + size_bytes > self._maximum_output_bytes: diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index f48875ee..6b211af5 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -89,10 +89,12 @@ class PreflightReport: @property def valid(self) -> tuple[CollectorCandidate, ...]: + """Return candidates that passed both static and runtime validation.""" return tuple(candidate for candidate in self.candidates if candidate.valid) @property def invalid(self) -> tuple[CollectorCandidate, ...]: + """Return candidates that must be blocked or quarantined.""" return tuple(candidate for candidate in self.candidates if not candidate.valid) def to_dict( @@ -186,10 +188,12 @@ def _diagnostic_line(path: Path, message: str) -> int: def _pascal_case(filename: str) -> str: + """Convert a snake-case collector filename to its required class name.""" return "".join(part.capitalize() for part in Path(filename).stem.split("_")) + "Collector" def _is_within(path: Path, root: Path) -> bool: + """Return whether a candidate path resolves inside its discovery root.""" try: path.resolve().relative_to(root.resolve()) except ValueError: @@ -198,6 +202,7 @@ def _is_within(path: Path, root: Path) -> bool: def _iter_candidates(project_root: Path, kind: CollectorKind) -> list[Path]: + """Enumerate eligible collector files for one trusted or opt-in source tree.""" root = project_root / ("core" if kind is CollectorKind.CORE else "plugins") if not root.exists(): return [] @@ -217,6 +222,7 @@ def _iter_candidates(project_root: Path, kind: CollectorKind) -> list[Path]: def _top_level_call_name(node: ast.Call) -> str | None: + """Return a stable dotted name for a simple AST call expression.""" if isinstance(node.func, ast.Name): return node.func.id if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name): @@ -228,6 +234,7 @@ class _ImportTimeCallFinder(ast.NodeVisitor): """Find calls whose expressions execute while Python imports a module.""" def __init__(self) -> None: + """Initialize an empty call collector for import-time AST expressions.""" self.calls: list[ast.Call] = [] def visit_Call(self, node: ast.Call) -> None: @@ -260,6 +267,7 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None: self.visit(item) def _visit_callable_header(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + """Visit decorators, annotations, and defaults evaluated at definition time.""" for decorator in node.decorator_list: self.visit(decorator) self._visit_arguments(node.args) @@ -267,6 +275,7 @@ def _visit_callable_header(self, node: ast.FunctionDef | ast.AsyncFunctionDef) - self.visit(node.returns) def _visit_arguments(self, arguments: ast.arguments) -> None: + """Visit argument defaults and annotations that execute during module import.""" for default in (*arguments.defaults, *arguments.kw_defaults): if default is not None: self.visit(default) @@ -329,6 +338,7 @@ def _validate_engine_boundary_imports(tree: ast.Module, candidate: CollectorCand def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: + """Apply filename, AST shape, import-boundary, and side-effect rules to a candidate.""" expected_class = _pascal_case(path.name) candidate = CollectorCandidate(path=path, kind=kind, expected_class=expected_class) if path.name in _VAGUE_NAMES and not (kind is CollectorKind.PLUGIN and path.name == "main.py"): @@ -786,6 +796,7 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> def _runtime_probe(project_root: Path, candidate: CollectorCandidate) -> None: + """Probe metadata in a short-lived restricted worker after static validation.""" engine_root = Path(__file__).resolve().parents[2] pythonpath = os.pathsep.join((str(engine_root), str(project_root))) command = [ diff --git a/logicytics/module/interaction.py b/logicytics/module/interaction.py index 69a8f629..06529bf4 100644 --- a/logicytics/module/interaction.py +++ b/logicytics/module/interaction.py @@ -43,10 +43,12 @@ class FlagMatch: def _normalized(value: str) -> str: + """Normalize free-form input into lowercase, whitespace-separated tokens.""" return " ".join(_TOKEN.findall(value.casefold())) def _score(query: str, flag: str, description: str) -> float: + """Score a query using sequence similarity and token overlap with a flag description.""" normalized_query = _normalized(query) candidate = _normalized(f"{flag} {description}") sequence = SequenceMatcher(None, normalized_query, candidate).ratio() diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index c7f4c627..0c9ea452 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -51,6 +51,7 @@ def __init__( *, console: TextIO | None = None, ) -> None: + """Initialize a bounded application logger with explicit file and console sinks.""" self.path = path.resolve() self.settings = settings self.console = sys.stderr if console is None else console @@ -182,6 +183,7 @@ class FileEventLogger(EventLogger): """Append JSONL events to a single run- or collector-owned log file.""" def __init__(self, path: Path, *, run_id: str, collector_id: str | None = None) -> None: + """Initialize an append-only JSONL logger scoped to one run and collector.""" self.path = path self.run_id = run_id self.collector_id = collector_id @@ -223,7 +225,9 @@ def timed( """Decorate a function so structured start, finish, error, and duration events are written.""" def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: + """Wrap one callable with lifecycle timing events.""" def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: + """Emit start, completion, or failure events around one invocation.""" logger.event(level, "function_started", function=function.__qualname__) started = perf_counter() try: @@ -257,7 +261,9 @@ def deprecated( """Decorate a function so each invocation emits a structured deprecation warning.""" def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: + """Wrap one callable with a structured deprecation warning.""" def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: + """Emit the configured warning and then invoke the deprecated callable.""" fields: dict[str, str] = {"function": function.__qualname__, "removal_version": removal_version, "reason": reason} if include_stack: diff --git a/logicytics/module/maintenance.py b/logicytics/module/maintenance.py index 5e870a44..199fdfec 100644 --- a/logicytics/module/maintenance.py +++ b/logicytics/module/maintenance.py @@ -249,6 +249,7 @@ def compare_versions(local: str, remote: str) -> str: """Compare semantic versions while treating snapshots as older than stable peers.""" def parsed(value: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], ...]]: + """Parse a supported semantic version into a comparable tuple.""" match = _VERSION.fullmatch(value) if match is None: raise ValueError("version comparison requires semantic versions") @@ -318,6 +319,7 @@ def maintenance_diagnostics( class DeveloperChecks(TypedDict): + """Static repository hygiene findings returned by developer checks.""" naming_violations: list[str] misplaced_python: list[str] missing_module_docstrings: list[str] diff --git a/logicytics/module/modes.py b/logicytics/module/modes.py index 56c8799c..e3169fb9 100644 --- a/logicytics/module/modes.py +++ b/logicytics/module/modes.py @@ -12,6 +12,7 @@ class ModeMatrixCollector(TypedDict): + """Serialized mode membership and validation details for one collector.""" id: str kind: str valid: bool @@ -22,6 +23,7 @@ class ModeMatrixCollector(TypedDict): class ModeMatrixMode(TypedDict): + """Serialized execution mode definition and its selected collectors.""" name: str description: str strategy: str @@ -30,6 +32,7 @@ class ModeMatrixMode(TypedDict): class ModeMatrix(TypedDict): + """Versioned machine-readable matrix of modes and collector memberships.""" schema_version: int modes: list[ModeMatrixMode] collectors: list[ModeMatrixCollector] diff --git a/logicytics/module/packaging.py b/logicytics/module/packaging.py index 282c754f..aec226df 100644 --- a/logicytics/module/packaging.py +++ b/logicytics/module/packaging.py @@ -41,6 +41,7 @@ def _package_filename(manifest: RunManifest) -> str: def _summary(manifest: RunManifest) -> str: + """Render a compact human-readable summary for a packaged run manifest.""" lines = [ f"Logicytics run: {manifest.run_id}", f"Action: {manifest.action}", diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index 7be5036d..5a4185b3 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -43,6 +43,7 @@ def _fingerprint(request: RunRequest, collectors: tuple[CollectorCandidate, ...] def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> bool: + """Return whether request flags and profile rules select this validated candidate.""" assert candidate.metadata is not None metadata = candidate.metadata if metadata.id in request.exclude: @@ -67,11 +68,13 @@ def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> def _topological_order(selected: dict[str, CollectorCandidate]) -> tuple[CollectorCandidate, ...]: + """Order selected collectors after resolving dependencies and rejecting cycles.""" ordered: list[CollectorCandidate] = [] visiting: set[str] = set() visited: set[str] = set() def visit(collector_id: str) -> None: + """Depth-first visit one dependency node exactly once.""" if collector_id in visited: return if collector_id in visiting: diff --git a/logicytics/module/redaction.py b/logicytics/module/redaction.py index 9bc28e6d..08ed389e 100644 --- a/logicytics/module/redaction.py +++ b/logicytics/module/redaction.py @@ -52,6 +52,7 @@ def redact_text(value: str) -> str: def _redact_value(value: Any) -> Any: + """Recursively sanitize nested mappings and sequences while preserving their shape.""" if isinstance(value, Mapping): return redact_mapping(value) if isinstance(value, list): diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index e1239e01..06382a11 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -76,12 +76,14 @@ def Process( *, daemon: bool | None = None, ) -> BaseProcess: + """Create a worker process using the configured multiprocessing context.""" ... def Queue( self, maxsize: int = 0, ) -> Queue[_T]: + """Create a typed result queue for worker-to-supervisor messages.""" ... @@ -120,6 +122,7 @@ class _WorkerMessage(TypedDict): @dataclass(slots=True) class _ActiveWorker: + """Track process, resource, workspace, and progress state for one active collector.""" candidate_id: str process: BaseProcess started_at: float @@ -170,6 +173,7 @@ def __init__( capabilities: tuple[Capability, ...], collector_source: Path, ) -> None: + """Install audit boundaries for one collector's filesystem and capability scope.""" self.roots = (workspace.resolve(), (artifact_root / collector_id.replace(".", "_")).resolve()) self.runtime_roots = (Path(sys.base_prefix).resolve(), Path(__file__).resolve().parent) self.collector_source = collector_source.resolve() @@ -186,6 +190,7 @@ def __init__( sys.addaudithook(self._check_event) def _check_path(self, value: object) -> None: + """Reject writes that escape the collector workspace or artifact root.""" if isinstance(value, int): return if not isinstance(value, (str, bytes, os.PathLike)): @@ -197,6 +202,7 @@ def _check_path(self, value: object) -> None: raise PermissionError(f"collector filesystem mutation escapes its private workspace: {path}") def _check_read(self, value: object) -> None: + """Enforce declared read, browser, sensitive-file, and private-key capabilities.""" if isinstance(value, int) or not isinstance(value, (str, bytes, os.PathLike)): return path = Path(os.fsdecode(value)).resolve() @@ -306,6 +312,7 @@ def _prohibited_subprocess(self, arguments: tuple[object, ...]) -> str | None: return None def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: + """Apply capability and mutation policy to one Python audit event.""" if not self.active: return if event == "subprocess.Popen": @@ -351,6 +358,7 @@ def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: def _load_collector(path: Path, expected_class: str): + """Load and instantiate the preflighted collector class from its source path.""" module_name = f"logicytics_runtime_{uuid4().hex}" spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: @@ -679,6 +687,7 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) def _serialize_result(result: CollectorResult) -> _SerializedResult: + """Convert a typed collector result into a queue-safe primitive payload.""" return { "status": result.status.value, "summary": result.summary, @@ -699,6 +708,7 @@ class RunSupervisor: """Plans process isolation, timeouts, manifests, and collector failure containment.""" def __init__(self, project_root: Path, configuration: AppConfig) -> None: + """Initialize a supervisor rooted at the project and configured output policy.""" self.project_root = project_root.resolve() self.configuration = configuration self._active_workers: dict[str, _ActiveWorker] = {} @@ -1357,6 +1367,7 @@ def _cancel_active( manifest_path: Path, reason: str, ) -> None: + """Terminate active workers, mark their records cancelled, and persist the manifest.""" for collector_id, worker in tuple(active.items()): self._terminate_process_tree(worker.process) active.pop(collector_id) @@ -1376,6 +1387,7 @@ def _cancel_records( manifest_path: Path, reason: str, ) -> None: + """Cancel active and still-planned records, then write the final cancellation state.""" self._cancel_active(self._active_workers, records, manifest, manifest_path, reason) for record in records.values(): if record.status in {"planned", "running"}: diff --git a/logicytics/module/validation_worker.py b/logicytics/module/validation_worker.py index 9ebc92e3..4a513f22 100644 --- a/logicytics/module/validation_worker.py +++ b/logicytics/module/validation_worker.py @@ -72,17 +72,21 @@ class _ValidationSideEffectGuard: } def __init__(self) -> None: + """Initialize an inactive audit guard for the validation context.""" self.active = False def __enter__(self) -> "_ValidationSideEffectGuard": + """Install the audit hook and begin rejecting validation side effects.""" sys.addaudithook(self._reject_side_effect) self.active = True return self def __exit__(self, *_: object) -> None: + """Stop enforcing validation side-effect checks after the probe exits.""" self.active = False def _reject_side_effect(self, event: str, arguments: tuple[object, ...]) -> None: + """Raise when an active validation probe attempts a blocked mutation.""" if not self.active: return if event == "open": @@ -100,6 +104,7 @@ def _reject_side_effect(self, event: str, arguments: tuple[object, ...]) -> None def _load_module(path: Path): + """Load one collector source module under an isolated probe module name.""" module_name = f"logicytics_probe_{path.stem}" spec = importlib.util.spec_from_file_location(module_name, path) if spec is None or spec.loader is None: @@ -113,6 +118,7 @@ def _validate_contract( collector_type: type[Collector], kind: CollectorKind, ) -> CollectorMetadata: + """Validate collector signatures, metadata consistency, and probe purity.""" required_base = CoreCollector if kind is CollectorKind.CORE else PluginCollector if not issubclass(collector_type, required_base): diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 8bffc45f..a4936696 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -205,20 +205,25 @@ class RegistryAdapter: @staticmethod def _module(): + """Return the optional Windows registry module or raise on unsupported hosts.""" if _winreg is None: raise OSError("the Windows registry is unavailable on this platform") return _winreg def OpenKey(self, key: Any, sub_key: str): + """Open one registry key through the platform boundary.""" return self._module().OpenKey(key, sub_key) def QueryValueEx(self, key: Any, value_name: str): + """Read one registry value through the platform boundary.""" return self._module().QueryValueEx(key, value_name) def EnumKey(self, key: Any, index: int) -> str: + """Return one child registry key by index.""" return self._module().EnumKey(key, index) def EnumValue(self, key: Any, index: int): + """Return one registry value tuple by index.""" return self._module().EnumValue(key, index) @staticmethod @@ -257,22 +262,27 @@ class NetworkAdapter: @staticmethod def gethostname() -> str: + """Return the local host name.""" return _socket.gethostname() @staticmethod def gethostbyname(hostname: str) -> str: + """Resolve one host name to an IPv4 address.""" return _socket.gethostbyname(hostname) @staticmethod def getaddrinfo(host: str, port: int | str | None): + """Resolve socket addresses for a host and port.""" return _socket.getaddrinfo(host, port) @staticmethod def inet_ntoa(packed_ip: bytes) -> str: + """Convert packed IPv4 bytes into dotted notation.""" return _socket.inet_ntoa(packed_ip) @staticmethod def socket(family: int = -1, type: int = -1, proto: int = -1, fileno: int | None = None): + """Create a socket through the centrally mockable network boundary.""" if fileno is None: return _socket.socket(family, type, proto) return _socket.socket(family, type, proto, fileno=fileno) @@ -286,6 +296,7 @@ class WindowsApiAdapter: @staticmethod def load_library(name: str): + """Load one validated Win32 DLL name when running on Windows.""" if ( not isinstance(name, str) or not name @@ -301,6 +312,7 @@ def load_library(name: str): return ctypes.WinDLL(name, use_last_error=True) def is_administrator(self) -> bool | None: + """Return administrator status, or None when the Win32 API is unavailable.""" try: return bool(self.load_library("shell32").IsUserAnAdmin()) except OSError: @@ -310,6 +322,7 @@ def process_working_set(self, process_id: int) -> int | None: """Return a Windows process working set without exposing raw Win32 handles.""" class ProcessMemoryCounters(ctypes.Structure): + """Minimal PROCESS_MEMORY_COUNTERS layout used by GetProcessMemoryInfo.""" _fields_ = [ ("cb", ctypes.c_ulong), ("PageFaultCount", ctypes.c_ulong), @@ -344,6 +357,7 @@ def process_descendants(self, parent_process_id: int) -> tuple[int, ...]: """Snapshot descendants of one Windows process through Toolhelp APIs.""" class ProcessEntry(ctypes.Structure): + """Minimal PROCESSENTRY32W layout used for descendant enumeration.""" _fields_ = [ ("dwSize", ctypes.c_ulong), ("cntUsage", ctypes.c_ulong), @@ -410,44 +424,54 @@ class FilesystemAdapter: @staticmethod def home() -> Path: + """Return the current user's home directory.""" return Path.home() @staticmethod def environment_path(name: str, fallback: Path) -> Path: + """Read a validated environment variable as a path with a fallback.""" if not isinstance(name, str) or not name or not name.replace("_", "").isalnum(): raise ValueError("environment path name must be an alphanumeric variable name") return Path(os.environ.get(name, fallback)) @staticmethod def system_drive_root() -> Path: + """Return the current Windows system-drive root path.""" return Path(os.environ.get("SystemDrive", "C:") + "\\") @staticmethod def children(path: Path): + """Iterate immediate children of a directory.""" return path.iterdir() @staticmethod def glob(path: Path, pattern: str): + """Match direct children using a pathlib glob pattern.""" return path.glob(pattern) @staticmethod def recursive(path: Path, pattern: str = "*"): + """Match descendants using a recursive pathlib pattern.""" return path.rglob(pattern) @staticmethod def walk(path: Path, **options: Any): + """Walk a filesystem tree through the shared adapter.""" return os.walk(path, **options) @staticmethod def scan(path: Path): + """Open a directory iterator through the shared adapter.""" return os.scandir(path) @staticmethod def copy_file(source: Path, destination: Path) -> None: + """Copy file contents and metadata to a destination path.""" shutil.copy2(source, destination) @staticmethod def disk_usage(path: Path): + """Return filesystem usage statistics for a path.""" return shutil.disk_usage(path) From 20f6460154432891aa61911ae5ebf30b15aa6e72 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 17:16:41 +0300 Subject: [PATCH 243/316] fix: finish v4 documentation and test migration Remove obsolete planning and status documents, refresh the supported configuration and migration guides, and align release documentation with the current package layout. Migrate stale tests to the YAML-only v4 contract and fix small runtime boundary issues surfaced by the final suite. --- CONTRIBUTING.md | 11 +- PLANS.md | 11 - core/storage/volume_details.py | 3 +- docs/CONFIGURATION.md | 102 ++- docs/FEATURE_STATUS.md | 70 -- docs/MIGRATION.md | 25 + docs/README.md | 7 - docs/TODO.md | 943 ---------------------- docs/V4_RELEASE.md | 9 +- logicytics/__init__.py | 2 - logicytics/cli/commands.py | 32 +- logicytics/module/api.py | 14 +- logicytics/module/mod_runner.py | 2 +- logicytics/module/runtime.py | 6 +- tests/golden/bios_info.html | 50 +- tests/test_collector_cancellation.py | 51 +- tests/test_configuration.py | 103 +-- tests/test_configuration_documentation.py | 20 +- tests/test_flow_matrix.py | 2 +- tests/test_logging.py | 2 +- tests/test_maintenance.py | 40 +- tests/test_packaging.py | 12 +- tests/test_platform_adapters.py | 15 +- tests/test_preflight.py | 25 +- tests/test_public_api.py | 8 +- tests/test_release_documentation.py | 62 +- tests/test_runtime.py | 8 +- tests/test_scheduler.py | 4 +- tests/test_shipped_collectors.py | 4 +- tests/test_worker_security.py | 2 +- 30 files changed, 310 insertions(+), 1335 deletions(-) delete mode 100644 PLANS.md delete mode 100644 docs/FEATURE_STATUS.md delete mode 100644 docs/README.md delete mode 100644 docs/TODO.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89fe4db7..c65f5a00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,17 +31,17 @@ The canonical pipeline is: `request -> validated plan -> isolated collectors -> registered artifacts -> manifest -> package` -- `logicytics/cli.py` parses and renders. It does not collect evidence. -- `logicytics/planner.py` resolves one deterministic plan from immutable +- `logicytics/cli/` parses and renders. It does not collect evidence. +- `logicytics/module/planner.py` resolves one deterministic plan from immutable `RunRequest` policy and validated metadata. -- `logicytics/runtime.py` owns per-run and per-worker lifecycle, cancellation, +- `logicytics/module/runtime.py` owns per-run and per-worker lifecycle, cancellation, retries, timeouts, failure aggregation, and post-run actions. - `logicytics/platform_adapters.py` owns host command, process, registry, filesystem, network, privilege, and Win32 access. Collectors must use these injectable seams instead of importing host APIs directly. -- `logicytics/artifacts.py` is the only publication path from collector +- `logicytics/module/artifacts.py` is the only publication path from collector workspaces into the run artifact catalog. -- `logicytics/packaging.py` consumes the finalized catalog; it never scans source +- `logicytics/module/packaging.py` consumes the finalized catalog; it never scans source directories for arbitrary files. - Maintenance, debug, update, developer, and usage behavior stays separate from normal collection. @@ -149,7 +149,6 @@ Keep user and developer documentation synchronized with behavior: - [MODS.md](docs/MODS.md): extension contract. - [MIGRATION.md](docs/MIGRATION.md): supported compatibility boundary. - [FLOW_MATRIX.md](docs/FLOW_MATRIX.md): executable flow evidence. -- [FEATURE_STATUS.md](docs/FEATURE_STATUS.md): TODO ownership and completion evidence. - [V4_RELEASE.md](docs/V4_RELEASE.md): v4 recreation scope and release verification. The repository wiki is complementary documentation, not a substitute for the diff --git a/PLANS.md b/PLANS.md deleted file mode 100644 index 9d5e2e2d..00000000 --- a/PLANS.md +++ /dev/null @@ -1,11 +0,0 @@ -# To-Do List - -> [!TIP] -> Here is a key for the table above: -> -> - ❌ ➡️ Might be done, Not sure yet -> - ✅ ➡️ Will be done, 100% sure - -| Task | Version | To Complete? | -|--------------|---------|--------------| -| No plans yet | vX.Y.Z | ✅ | diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index 2f3637ef..d192ac89 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -2,7 +2,6 @@ from __future__ import annotations -import ctypes import json from datetime import datetime, timezone @@ -21,7 +20,7 @@ def _volume_details() -> list[dict[str, int | str]]: kernel32 = windows_api_adapter.load_library("kernel32") mask = kernel32.GetLogicalDrives() if mask == 0: - raise ctypes.WinError() + raise OSError("GetLogicalDrives failed") volumes: list[dict[str, int | str]] = [] for offset in range(26): if not mask & (1 << offset): diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 62ed79db..e161b688 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1,9 +1,103 @@ # Configuration -Logicytics has one authoritative user configuration: [`logicytics.yaml`](../logicytics.yaml) at the application root. The installer creates it when needed, repair workflows may update it, and users may edit it directly. +Logicytics has one authoritative user configuration: [`logicytics.yaml`](../logicytics.yaml) at the application root. The installer creates it when needed, repair workflows may update it, and users may edit it directly. The parser accepts a strict, mapping-only YAML subset; JSON is valid only where it is also valid YAML. -The file uses a strict, mapping-only YAML subset. Invalid indentation, duplicate keys, unsupported settings, unsafe paths, non-finite numbers, and invalid values stop planning before collectors run. +Unknown keys, duplicate keys, unsafe paths, non-finite numbers, unsupported values, and files larger than 2 MiB are rejected before planning. Invocation-only choices such as profiles, collector selection, capabilities, authorization, scheduling, reruns, package policy, and power actions belong to `RunRequest`, not this file. -Key controls include the output root, bounded worker limits, console and file-log policy, collector-specific safe limits, and Sysinternals setup. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction entirely. +## Root sections -For the complete configuration reference and examples, use the [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki). +The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, `logging`, and `collectors`. The current schema is version `4`. + +| Section | Fields | +| --- | --- | +| `runtime` | `output_root`, `default_max_workers`, `maximum_workers`, `package_completed_runs`, `maximum_run_output_bytes` | +| `interaction` | `history_enabled`, `similarity_threshold`, `model_name`, `model_debug` | +| `maintenance` | `remote_manifest_url`, `remote_manifest_sha256`, `local_manifest_path`, `minimum_python`, `recommended_python`, `sysinternals_enabled`, `sysinternals_download_url` | +| `logging` | `level`, `console_enabled`, `color_enabled`, `file_enabled`, `maximum_bytes`, `delete_previous`, `retention_days` | + +`runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. + +## Collector settings + +The `collectors` mapping is keyed by a validated collector ID. Shipped collectors reject settings they do not declare. Extension IDs may define their own settings. The shipped configurable fields are: + +| Collector | Fields and bounds | +| --- | --- | +| `core.network.bandwidth_sample` | `sample_count` 1–10; `interval_seconds` 0.1–60 | +| `core.packet.packet_capture` | `packet_count` 1–10000; `timeout_seconds` 1–60; `retry_window_seconds` 0–60; `interface` text | +| `core.filesystem.system_drive_tree` | `max_entries` 1–50000; `max_depth` 1–32 | +| `core.filesystem.system_drive_listing` | `max_entries` 1–50000; `max_depth` 1–32 | +| `core.filesystem.sensitive_file_inventory` | `root` absolute path; `max_directories` 1–50000; `max_matches` 1–5000 | +| `core.process.memory_map` | `max_regions` 1–100000; `output_limit_bytes` 1024–67108864; `disk_safety_margin_bytes` 0–68719476736; `dump_directory` relative workspace path | + +## Complete example + +```json +{ + "schema_version": 4, + "runtime": { + "output_root": "output/data", + "default_max_workers": 4, + "maximum_workers": 16, + "package_completed_runs": true, + "maximum_run_output_bytes": 4294967296 + }, + "interaction": { + "history_enabled": false, + "similarity_threshold": 0.55, + "model_name": "stdlib-sequence-matcher", + "model_debug": false + }, + "maintenance": { + "remote_manifest_url": null, + "remote_manifest_sha256": null, + "local_manifest_path": "project.manifest.json", + "minimum_python": "3.11", + "recommended_python": "3.11", + "sysinternals_enabled": true, + "sysinternals_download_url": "https://download.sysinternals.com/files/SysinternalsSuite.zip" + }, + "logging": { + "level": "INFO", + "console_enabled": true, + "color_enabled": true, + "file_enabled": true, + "maximum_bytes": 4194304, + "delete_previous": false, + "retention_days": 30 + }, + "collectors": { + "core.network.bandwidth_sample": { + "sample_count": 3, + "interval_seconds": 1 + }, + "core.packet.packet_capture": { + "packet_count": 100, + "timeout_seconds": 10, + "retry_window_seconds": 0, + "interface": "Ethernet" + }, + "core.filesystem.system_drive_tree": { + "max_entries": 5000, + "max_depth": 4 + }, + "core.filesystem.system_drive_listing": { + "max_entries": 5000, + "max_depth": 4 + }, + "core.filesystem.sensitive_file_inventory": { + "root": "C:\\Evidence", + "max_directories": 1000, + "max_matches": 100 + }, + "core.process.memory_map": { + "max_regions": 5000, + "output_limit_bytes": 67108864, + "disk_safety_margin_bytes": 104857600, + "dump_directory": "memory_maps" + } + } +} +``` + +The parser-backed tests validate this example and the field inventory. For installation, migration guidance, and troubleshooting, see the [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki). diff --git a/docs/FEATURE_STATUS.md b/docs/FEATURE_STATUS.md deleted file mode 100644 index 686aa7eb..00000000 --- a/docs/FEATURE_STATUS.md +++ /dev/null @@ -1,70 +0,0 @@ -# v4 feature ownership and status - -This register maps every section in [TODO.md](TODO.md) to its implementation -owner, current status, and primary evidence. Every checklist item inherits the -owner of its nearest heading; the checkbox beside that item is the authoritative -item-level status. The separate GitHub wiki was synchronized for v4.0 in wiki -commit `eaf7628`. - -| TODO section | Owner | Status | Primary evidence | -|-------------------------------------------------------|----------------------------------------|----------|-------------------------------------------------------------------------------| -| Core product redesign — mandatory v4.0 change | Architecture maintainers | Complete | `logicytics/contracts.py`, `planner.py`, `runtime.py`, `packaging.py` | -| Core functionality status | Runtime maintainers | Complete | `tests/test_core_functionality.py`, `tests/test_flow_matrix.py` | -| Core and plugin layout — canonical v4.0 model | Discovery maintainers | Complete | `core/`, `plugins/`, `logicytics/discovery.py` | -| Strict collector file, class, and specialty standards | Collector contract maintainers | Complete | `logicytics/contracts.py`, `discovery.py`, `tests/test_shipped_collectors.py` | -| File and folder rules | Discovery maintainers | Complete | `logicytics/discovery.py`, static preflight tests | -| Class and identity rules | Collector contract maintainers | Complete | `CollectorMetadata`, strict preflight tests | -| Specialty rules | Collector contract maintainers | Complete | `Specialty`, shipped collector catalog tests | -| Required class interface | Collector contract maintainers | Complete | `CoreCollector`, `PluginCollector`, lifecycle tests | -| Static and runtime preflight validation | Discovery maintainers | Complete | `logicytics/discovery.py`, `validation_worker.py` | -| Per-collector sandbox and failure isolation | Runtime maintainers | Complete | `logicytics/runtime.py`, cancellation/isolation tests | -| Replace the current architectural model | Architecture maintainers | Complete | v4 packages and `MIGRATION.md` | -| Define the v4.0 run model | Runtime and manifest maintainers | Complete | `runtime.py`, `manifest.py`, package integration tests | -| Separate responsibilities into layers | Architecture maintainers | Complete | CLI, planner, runtime, adapters, artifacts, packaging, maintenance modules | -| Make collection safe, bounded, and observable | Runtime and security maintainers | Complete | capability metadata, worker limits, progress/failure tests | -| Make concurrency a planned capability | Planner/runtime maintainers | Complete | resource scheduler and concurrency tests | -| Make configuration and profiles understandable | Configuration/planner maintainers | Complete | `CONFIGURATION.md`, `configuration.py`, profile tests | -| Improve the extension model | Extension maintainers | Complete | `MODS.md`, plugin/MOD discovery and isolation tests | -| Improve packaging and evidence integrity | Artifact/packaging maintainers | Complete | `OUTPUTS.md`, package/hash reproduction tests | -| Improve the public API and testability | API maintainers | Complete | `logicytics/api.py`, public API tests | -| Migration rules from the old design | Compatibility maintainers | Complete | `MIGRATION.md`, migration regression tests | -| v4.0 goals | Release maintainers | Complete | `V4_RELEASE.md` and full release gates | -| 1. Main engine and execution lifecycle | Runtime maintainers | Complete | CLI/planner/runtime integration tests | -| Execution modes | Mode/planner maintainers | Complete | `logicytics/modes.py`, machine-readable mode matrix tests | -| Post-run actions | Runtime maintainers | Complete | typed shutdown/reboot package-gating tests | -| 2. Command-line and interaction features | CLI/interaction maintainers | Complete | `cli.py`, `interaction.py`, usage/matching tests | -| 3. Core Logicytics engine/library API | API maintainers | Complete | public lazy API and integration tests | -| Command execution | Platform adapter maintainers | Complete | `ProcessAdapter`, command/MOD tests | -| File discovery | Discovery maintainers | Complete | `file_listing.py`, recursive discovery tests | -| Environment checks | Platform adapter maintainers | Complete | `environment.py`, adapter and live Windows tests | -| Configuration | Configuration maintainers | Complete | `CONFIGURATION.md`, parser/migration tests | -| Logging | Logging maintainers | Complete | `logging.py`, singleton/redaction/retention tests | -| Output and packaging | Artifact/packaging maintainers | Complete | `artifacts.py`, `packaging.py`, `OUTPUTS.md` | -| 4. Standard system-data collectors | Core collector maintainers | Complete | 66-collector preflight, lifecycle, output, and Windows tests | -| System, hardware, and operating-system inventory | System/hardware collector maintainers | Complete | `core/system/`, `core/hardware/` | -| Network inventory and activity | Network/packet collector maintainers | Complete | `core/network/`, `core/packet/` | -| Wireless, Bluetooth, and removable-device history | Wireless/device collector maintainers | Complete | `core/wireless/`, `core/bluetooth/`, `core/usb/` | -| Windows logs, registry, storage, and encryption | Windows evidence collector maintainers | Complete | `core/event_log/`, `registry/`, `storage/`, `encryption/` | -| Memory and process diagnostics | Process/memory collector maintainers | Complete | `core/process/`, `core/memory/` | -| User files and media | File evidence collector maintainers | Complete | `core/browser/`, `filesystem/`, `media/`, `ssh/` | -| Sysinternals integration | Diagnostics collector maintainers | Complete | `core/diagnostics/sysinternals_report.py`, `sysinternals.py` | -| 5. Maintenance and developer features | Maintenance maintainers | Complete | `maintenance.py`, CLI maintenance flow tests | -| Debugger (`--debug`) | Maintenance maintainers | Complete | debug diagnostics tests and `FLOW_MATRIX.md` | -| Update action (`--update`) | Maintenance maintainers | Complete | authenticated/read-only check and explicit apply tests | -| Developer action (`--dev`) | Maintenance maintainers | Complete | integrity/version/developer check tests | -| 6. MODS extension system | Extension maintainers | Complete | `MODS.md`, MOD runner and package tests | -| 7. Configuration and output contract | Configuration/artifact maintainers | Complete | `CONFIGURATION.md`, `OUTPUTS.md`, contract tests | -| 8. v4.0 delivery phases | Release maintainers | Complete | phases 1-5 complete, including wiki commit `eaf7628` | -| Phase 1 — Foundation | Architecture maintainers | Complete | contracts, configuration, logging, adapters, run workspace | -| Phase 2 — Engine and packaging | Runtime/packaging maintainers | Complete | scheduling, modes, ZIP/hash, MOD packaging tests | -| Phase 3 — Core collectors | Core collector maintainers | Complete | all 66 core collectors pass strict preflight | -| Phase 4 — Deep collection and visualization | Deep-collection maintainers | Complete | deep profile, packet graph, usage SVG tests | -| Phase 5 — Maintenance and release readiness | Release maintainers | Complete | repository release gates and wiki commit `eaf7628` | -| 9. v4.0 completion checklist | Release maintainers | Complete | all repository and wiki checklist items verified | - -## Maintenance rule - -Any new TODO heading must be added to this register in the same commit. Any item -whose owner, status, or evidence changes must update this table and the relevant -versioned contract document. The documentation contract test enforces exact -heading coverage so features cannot become ownerless. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 8c780660..1d105ce2 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -4,4 +4,29 @@ Logicytics v4 uses `logicytics.yaml` as its sole settings source and keeps appli Run `python -m logicytics.cli.installer` to prepare the virtual environment and root YAML configuration, then run `python -m logicytics preflight` from that environment. +## Command aliases + +The old mode flags remain explicit compatibility aliases. Their v4 equivalents are: + +| Legacy flag | v4 mode | +| --- | --- | +| `--default` | `--mode standard` | +| `--threaded` | `--mode balanced` | +| `--minimal` | `--mode quick` | +| `--depth` | `--mode thorough` | +| `--modded` | `--mode extensions` | +| `--nopy` | `--mode non-python` | +| `--performance-check` | `--mode performance` | + +Only one mode or alias may be selected per invocation. Prefer named `--mode` values in new automation. + +## File and collector bridges + +- `CODE/config.ini` is read only as a legacy compatibility input; v4 writes canonical manifests and does not mutate the file. +- `core.integration.legacy_code_outputs` remains the explicit bridge for approved legacy `CODE` output. It is not an implicit scan of the repository. +- `MODS/` is an opt-in extension area. MOD sidecars declare the executable, capabilities, output paths, and media types before a run can select them. +- There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/` stores. Run-owned evidence is written below the configured `runtime.output_root` and each run has its own manifest, logs, artifacts, and package hashes. + +Do not copy v3 JSON settings into the v4 YAML path. Translate supported values using [CONFIGURATION.md](CONFIGURATION.md), then run `python -m logicytics preflight` before collecting. + Technical migration guidance is maintained in the [Logicytics Wiki](https://github.com/DefinetlyNotAI/Logicytics/wiki). diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index bff8f5c8..00000000 --- a/docs/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# About docs - -Please understand this is NOT the projects docs, but rather the v3 → v4 system change docs that were used in development - -This folder may be removed/replaced in the future - -The real projects docs are found in the Repo's Wiki diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 87d0550d..00000000 --- a/docs/TODO.md +++ /dev/null @@ -1,943 +0,0 @@ -# Logicytics v4.0 Recreation TODO - -This is the complete feature inventory and recreation plan for Logicytics v4.0. -The checklist describes what Logicytics does and what v4.0 must provide; it does -not describe the quality of the current implementation. - -## Core product redesign — mandatory v4.0 change - -The v4.0 recreation must not be a collection of scripts launched from a large -dispatcher. The core idea must change to a run-oriented evidence pipeline: - -`request -> validated plan -> isolated collectors -> normalized artifacts -> manifest -> package` - -Every feature in this file remains in scope, but each feature must fit that -pipeline. A collector is a producer of evidence, not an application that decides -where the whole product stores files, how the process exits, or how another -collector is launched. - -### Core functionality status - -- [x] v4 project metadata and standard-library-only engine foundation. -- [x] Typed collector contracts, metadata, statuses, run requests, validation, and - artifact interfaces. -- [x] Typed local configuration with validated worker and output limits. -- [x] Strict discovery/preflight for `../core` and `../plugins`, including filename, - class, inheritance, method, docstring, annotation, metadata, specialty, and - ID validation. -- [x] Short-lived isolated metadata probe and invalid-plugin quarantine policy. -- [x] Deterministic profile/include/exclude planning with dependency ordering. -- [x] One worker process and private workspace per collector, with per-collector - timeout containment and independent failure results. -- [x] Workspace-bound artifact registration, size limits, checksums, run manifests, - ZIP creation, and package-level SHA-256 sidecars. -- [x] `python -m logicytics` commands for preflight, planning, and supervised runs. -- [x] Core integration tests for strict preflight, quarantine, isolated execution, - artifact boundaries, manifests, and packaging. -- [x] Structured engine/collector JSONL logging, capability enforcement, - cancellation propagation, Windows process-tree cleanup, and per-collector - duration/progress/artifact accounting. -- [x] Add the first actual shipped collector: `core.system.system_info`, a bounded - non-sensitive system inventory that establishes the strict collector pattern. -- [x] Add `core.process.running_processes`, a capability-gated, bounded, - non-verbose Tasklist CSV collector that skips expected access denials without - affecting unrelated collectors. -- [x] Add `core.network.network_identity`, a capability-gated hostname/address - report that does not probe remote hosts. -- [x] Add `core.memory.memory_snapshot`, a capability-free Windows aggregate - physical, virtual, and page-file memory report. -- [x] Add `core.storage.logical_drives`, a capability-free logical-drive type and - capacity report that does not enumerate file contents. -- [x] Add `core.hardware.windows_features`, a deep-profile, subprocess-gated - optional-Windows-feature inventory. -- [x] Add `core.event_log.system_events`, a deep-profile, subprocess-gated, - bounded System event-log CSV export. -- [x] Add `core.network.arp_cache`, a deep-profile, subprocess-gated, read-only - ARP-cache report that sends no network traffic. -- [x] Add `core.network.routing_table`, a deep-profile, subprocess-gated, - read-only IPv4 and IPv6 routing-table report. -- [x] Add `core.system.system_details`, a deep-profile, subprocess-gated, - complete `systeminfo` text report. -- [x] Add `core.system.bios_info`, a deep-profile, subprocess-gated BIOS CIM - inventory that renders a portable HTML evidence table. -- [x] Add `core.system.operating_system`, a deep-profile, subprocess-gated CIM - report with OS caption, version, service-pack, language, and path details. -- [x] Add `core.system.computer_system`, a deep-profile, subprocess-gated CIM - report with computer model, manufacturer, and processor counts. -- [x] Add `core.storage.physical_disks`, a deep-profile, subprocess-gated CIM - inventory of physical disk models, interface types, and capacities. -- [x] Add `core.system.session_snapshot`, a deep-profile, subprocess-gated report - with Windows build, memory, current user/SID, language, host, time, and drive. -- [x] Add `core.process.detailed_processes`, a deep-profile, subprocess-gated, - bounded verbose Tasklist CSV report. -- [x] Add `core.network.active_connections`, a deep-profile, subprocess-gated, - bounded Netstat report with endpoints, protocol, state, and owning PID. -- [x] Add `core.network.adapter_statistics`, a deep-profile, subprocess-gated, - per-interface byte, packet, error, and discard-counter inventory. -- [x] Add `core.network.network_interfaces`, a deep-profile, subprocess-gated - IPv4 address, netmask, broadcast, link-state, speed, and duplex inventory. -- [x] Add `core.network.connection_processes`, a deep-profile, subprocess-gated - Netstat and Tasklist correlation report for connection process names and PIDs. -- [x] Add `core.wireless.wifi_profiles`, a deep-profile, subprocess-gated, - saved Wi-Fi profile-name inventory that never reads key material. -- [x] Add `core.event_log.application_events`, a deep-profile, subprocess-gated, - bounded Application event-log CSV export. -- [x] Add `core.storage.mounted_volumes`, a deep-profile, subprocess-gated, - mounted volume-GUID and mount-point mapping report. -- [x] Add `core.encryption.bitlocker_status`, a deep-profile, subprocess-gated, - read-only `manage-bde -status` report. -- [x] Add `core.encryption.bitlocker_volumes`, a deep-profile, subprocess-gated - `Get-BitLockerVolume` JSON report. -- [x] Add `core.system.system_diagnostics`, a deep-profile, subprocess-gated - architecture, CPU, page-size, and boot-time report. -- [x] Add `core.event_log.security_events`, a deep-profile, subprocess-gated, - bounded Security event-log CSV export. -- [x] Add `core.process.process_memory`, a deep-profile, subprocess-gated - aggregate per-process working-set, private, and virtual memory report. -- [x] Add `core.filesystem.system_drive_tree`, a deep-profile, filesystem-read, - bounded recursive system-drive tree with configurable depth and entry limits. -- [x] Add `core.filesystem.system_drive_listing`, a deep-profile, filesystem-read, - bounded recursive system-drive listing scheduled by the run supervisor. -- [x] Add `core.network.bandwidth_sample`, a deep-profile, subprocess-gated, - configurable local adapter bandwidth sampler. -- [x] Add `core.bluetooth.bluetooth_addresses`, a deep-profile, subprocess-gated, - paired Bluetooth name and address-like PnP identifier report. -- [x] Add `core.system.installed_updates`, a deep-profile, subprocess-gated - installed Windows hotfix inventory. -- [x] Add `core.registry.installed_applications`, a deep-profile, registry-read - installed application inventory across 64-bit and WOW6432Node uninstall keys. -- [x] Add `core.system.windows_services`, a deep-profile, subprocess-gated Windows - service inventory with state, startup, account, path, and process metadata. -- [x] Add `core.registry.startup_applications`, a deep-profile, registry-read - inventory of standard user and machine Run/RunOnce startup entries. -- [x] Add `core.hardware.battery_status`, a deep-profile, subprocess-gated battery - charge, capacity, and runtime inventory. -- [x] Add `core.network.dns_cache`, a deep-profile, subprocess-gated sensitive DNS - resolver-cache export that sends no network traffic. -- [x] Add `core.network.firewall_profiles`, a deep-profile, subprocess-gated - read-only Domain, Private, and Public firewall-profile inventory. -- [x] Add `core.system.environment_posture`, a deep-profile, subprocess-gated - read-only administrator, UAC, and PowerShell execution-policy report. -- [x] Add `core.wireless.wifi_interfaces`, a deep-profile, subprocess-gated - Wi-Fi interface report with normalized `Wi-Fi`/`WiFi` interface names. -- [x] Add `core.wireless.wifi_profile_keys`, an explicitly sensitive-data-gated - deep collector that exports saved Wi-Fi profile XML with key material. -- [x] Add `core.registry.hklm_backup`, an explicitly sensitive-data-gated deep - collector that exports the local HKLM hive as a `.reg` backup. -- [x] Add `core.ssh.ssh_backup`, an explicitly sensitive-data-gated deep collector - that archives the current user's bounded `.ssh` keys and configuration. -- [x] Add `core.media.media_backup`, an explicitly sensitive-data-gated deep collector - that copies bounded JPG, JPEG, PNG, and MP4 evidence from Pictures and Videos. -- [x] Add `core.browser.browser_data_backup`, an explicitly sensitive-data-gated deep - collector for bounded Edge, Chrome, Firefox, Opera, and Opera GX profile evidence. -- [x] Add `core.bluetooth.bluetooth_history`, a timestamped deep snapshot whose - retained run artifacts provide isolated Bluetooth history evidence. -- [x] Add `core.packet.packet_capture`, an explicitly packet-capture/elevation-gated - IPv4 metadata collector with configurable count and timeout settings. -- [x] Add `core.filesystem.sensitive_file_inventory`, an explicitly sensitive-data-gated - deep collector that finds supported sensitive-named files and copies matches concurrently. -- [x] Add `core.system.windows_system_data_backup`, an explicitly sensitive-data-gated - deep collector that copies bounded Group Policy, event-log, and Defender support data. -- [x] Add `core.packet.connection_graph`, a deep subprocess-gated DOT export of - source/destination connection edges with protocol labels. -- [x] Add `core.diagnostics.sysinternals_report`, a deep collector that reports - Sysinternals archive/binary state and consolidates available supported tool output. -- [x] Add `core.process.memory_map`, a deep metadata-only memory-region collector - with bounded readable addresses, sizes, permissions, mapped paths, and indexes. -- [x] Add `core.storage.volume_details`, a capability-free detailed logical-volume - report with type, filesystem, label, free space, and capacity. -- [x] Add `core.network.network_adapters`, a subprocess-gated `ipconfig /all` - adapter inventory. -- [x] Add `core.bluetooth.paired_devices`, a deep-profile, subprocess-gated, - read-only PnP Bluetooth device inventory. -- [x] Add `core.usb.usb_storage_inventory`, a deep-profile, registry-read-gated - USB storage inventory. -- [x] Add `core.system.installed_drivers`, a deep-profile, subprocess-gated - detailed Windows driver inventory. -- [x] Add `core.system.group_policy`, a deep-profile, subprocess-gated local - Group Policy Result summary. - -## Core and plugin layout — canonical v4.0 model - -The shipped product and user extensions must be physically and conceptually -separate: - -```text -Logicytics/ -├── core/ # shipped, trusted-by-default collectors -│ ├── system/ -│ │ └── system_info.py -│ ├── network/ -│ │ └── network_info.py -│ └── ... -├── plugins/ # user-added collectors and integrations -│ ├── example_plugin.py -│ └── ... -└── logicytics/ # engine, contracts, planner, runtime -``` - -- [x] Make `../core` the only source of shipped collection functionality. -- [x] Automatically discover and validate every eligible collector in `../core` at - startup; core collectors are included by the selected built-in profile unless - a profile explicitly excludes them. -- [x] Make `../plugins` an opt-in user extension area. Discover plugins automatically - during preflight, but only run valid plugins when the user selects the plugin - profile/flag or explicitly includes them. -- [x] Permit plugins to implement any useful collection specialty, including new - Windows data sources, reports, visualizations, exports, or integrations, as - long as they obey the v4.0 collector contract. -- [x] Keep plugin output, logs, permissions, and failures separated from core - output while still including both in the final run manifest when selected. -- [x] Never silently execute arbitrary `.py` files merely because they exist in - `../core` or `../plugins`. -- [x] Ignore caches, tests, examples, private helpers, virtual environments, and - files beginning with `_` during collector discovery. -- [x] Require an explicit `plugin_id` and detect duplicate IDs before any collector - launches. -- [x] Decide and document whether an invalid core collector blocks the release/run - and whether an invalid plugin blocks only that plugin or the whole requested - plugin profile. The default policy should be: invalid core = launch refusal; - invalid explicitly selected plugin = launch refusal; invalid unselected - plugin = quarantine and report without executing it. - -### Strict collector file, class, and specialty standards - -Every executable collector must be a deliberately shaped Python module. A file -that fails any mandatory rule is not a collector and must not be launched. - -#### File and folder rules - -- [x] Require core collectors at `core//.py`. -- [x] Require plugins at `plugins/.py` or, for multi-file plugins, - `plugins//main.py` with all support files below that plugin's - directory. -- [x] Require lowercase `snake_case.py` filenames containing only letters, digits, - and underscores. -- [x] Require the filename to describe one specialty and one primary operation; - reject vague names such as `misc.py`, `utils.py`, `stuff.py`, or `main.py` - outside a plugin package. -- [x] Require one runnable collector per file. Shared helpers belong in a clearly - marked support module and may not be discovered as collectors. -- [x] Require UTF-8 source, valid Python syntax, and a module docstring explaining - the collector's purpose and data category. -- [x] Reject symlinks/junctions that resolve outside the project and reject plugin - support files that escape their plugin directory. - -#### Class and identity rules - -- [x] Require exactly one public collector class in each runnable module. -- [x] Require the class name to be the filename converted to PascalCase followed by - `Collector`; for example `network_info.py` must expose - `NetworkInfoCollector`. -- [x] Require core classes to inherit from `CoreCollector`. -- [x] Require plugin classes to inherit from `PluginCollector`. -- [x] Reject a module if the required class is missing, misspelled, duplicated, or - has a second public class that could be mistaken for the collector. -- [x] Require a stable, lowercase dotted ID matching its location and class, such as - `core.network.network_info` or `plugin.example_plugin`. -- [x] Require class metadata constants or an immutable metadata object for: - `ID`, `NAME`, `VERSION`, `SPECIALTY`, `DESCRIPTION`, `AUTHOR`, and - `SUPPORTED_PLATFORMS`. -- [x] Require plugin metadata to additionally declare requested capabilities, - privilege level, sensitive-data categories, network access, estimated cost, - timeout, maximum output, and minimum contract version. -- [x] Require IDs, names, specialties, and versions to match their schemas and - reject duplicates or unsupported contract versions before launch. - -#### Specialty rules - -- [x] Require `SPECIALTY` to be exactly one registered value: core collectors use - the closed built-in registry while plugins may register a schema-safe extension. - Initial values cover system, hardware, process, memory, - filesystem, network, packet, wireless, Bluetooth, USB, browser, registry, - event-log, storage, encryption, media, SSH, diagnostics, reporting, and - integration. -- [x] Require each collector to have one primary specialty. Related secondary - categories may be declared as metadata but may not change the collector's - identity. -- [x] Require the specialty to match the declared artifact types and the collector's - documented responsibility. -- [x] Reject a collector whose name, metadata, class, or outputs claim one specialty - while its implementation declares or performs an unrelated primary job. -- [x] Require a separate collector when a feature has a different permission model, - timeout profile, sensitive-data classification, or output contract. - -#### Required class interface - -- [x] Require the following methods with exact names and type-checked signatures: - - [x] `metadata() -> CollectorMetadata`. - - [x] `validate(context: CollectorContext) -> ValidationResult`. - - [x] `collect(context: CollectorContext) -> CollectorResult`. - - [x] `cleanup(context: CollectorContext) -> None`. -- [x] Permit optional `estimate(context) -> CollectionEstimate` and - `dependencies() -> tuple[str, ...]` methods only when they pass the contract - validator. -- [x] Require `collect()` to write through the artifact service and return artifact - references; direct package manipulation is forbidden. -- [x] Require collectors to report structured progress and never use undocumented - console output as their API. -- [x] Require all expected failures to become typed result/error values rather than - process exits. -- [x] Require docstrings for the class and every public method. -- [x] Reject import-time collection, threads, subprocesses, network calls, file - writes, registry writes, prompts, or `sys.exit()` calls. - -#### Static and runtime preflight validation - -- [x] Parse every discovered module with the Python AST before importing it. -- [x] Validate path, filename, module docstring, imports, public names, class - inheritance, class name, metadata, method signatures, type annotations, and - forbidden top-level statements. -- [x] Import each candidate in a short-lived validation subprocess with collection - disabled and with a restricted environment. -- [x] Instantiate the class only after static validation succeeds. -- [x] Validate metadata, dependencies, supported platform, requested capabilities, - output declarations, timeout, and size limits at runtime. -- [x] Run a no-op contract probe against `validate()` and reject unexpected side - effects, hangs, exceptions, or malformed return values. -- [x] Produce a preflight report listing valid, quarantined, and invalid collectors, - with exact file/line/rule failures. -- [x] Refuse to launch before collection if a core collector or explicitly selected - plugin fails validation. -- [x] Never “best effort” execute a malformed collector because another collector - succeeded. -- [x] Cache validation only with a source hash, interpreter version, contract - version, and configuration hash; invalidate the cache whenever any changes. - -### Per-collector sandbox and failure isolation - -“Sandboxed” means every collector is isolated as an independently supervised unit; -it does not mean that a collector automatically has permission to access anything -on the machine. Access must be declared, approved, and enforced as far as the -Windows platform allows. - -- [x] Launch each collector in its own worker process rather than importing and - running all collectors in the main process. -- [x] Give each collector a private working directory: - `output/data//collectors//`. -- [x] Give each collector a private temporary directory, stdout/stderr capture, - structured event channel, and artifact staging area. -- [x] Pass only a serializable `CollectorContext`/request payload into the worker; - do not share mutable engine objects across collectors. -- [x] Enforce declared timeout, memory/output limits, file-count limits, and - cancellation through the supervisor. -- [x] Terminate and clean up only the failed collector's process and workspace when - it crashes, hangs, exceeds limits, or is cancelled. -- [x] Mark that collector `failed`/`cancelled` with its traceback stored in the - run manifest, while allowing independent collectors to finish. -- [x] Prevent a collector from changing another collector's files, result state, - logger configuration, environment, or execution plan. -- [x] Restrict artifact registration to paths inside the collector workspace and - copy/stream only approved artifacts into the run artifact store. -- [x] Provide declared capability gates for filesystem reads, registry reads, - subprocesses, network capture, raw packet access, browser-data access, and - sensitive-file access. -- [x] Require elevated privilege and explicit user approval for collectors that need - it; do not grant administrator access to every plugin by default. -- [x] Capture subprocess trees so child processes do not survive a failed collector. -- [x] Use OS/process isolation as the default boundary; treat Python-level import - restrictions as validation and defense-in-depth, not as a complete sandbox. -- [x] Ensure a collector cannot call the main CLI, trigger reboot/shutdown, alter - configuration, install packages, update the repository, or launch another - collector through the public context API. -- [x] Provide a supervisor heartbeat and last-progress timestamp for every worker. -- [x] Make the final package include the isolation result and failure reason for - every selected collector. - -### Replace the current architectural model - -- [x] Replace global action/sub-action state with an immutable `RunRequest` that - contains the selected profile, explicit collector selections, concurrency, - output policy, and post-run action; keep the mutable cancellation handle in - the per-run `CollectorContext` rather than the immutable request. -- [x] Replace hard-coded filename lists and mode-specific branches with a typed - collector registry. Each collector declares its ID, category, supported - platforms, required privileges, estimated cost, dependencies, output types, - default profiles, and whether it is safe to parallelize. -- [x] Replace scripts writing directly into the repository with a `CollectorContext` that - gives each collector a run ID, isolated workspace, logger, configuration - snapshot, artifact writer, and cancellation handle. -- [x] Replace implicit current-working-directory behavior with explicit absolute - paths owned by the run context. -- [x] Replace `print`, `exit`, and process-level shutdowns inside collectors with - typed results and typed errors returned to the orchestrator. -- [x] Replace one giant execution-list algorithm with a planner that resolves - collector dependencies, removes duplicates, validates conflicts, and emits a - reproducible execution plan before any collection begins. -- [x] Replace mode names that imply implementation details with user-facing - collection profiles. Preserve compatibility aliases for existing flags, but - make profiles the canonical internal concept. -- [x] Replace a single shared mutable logger with run-scoped structured logging - that can still be rendered to the existing console/log-file formats. -- [x] Replace ad hoc output filenames with an artifact catalog containing stable - artifact IDs, human-readable names, MIME/type information, byte counts, - timestamps, producer collector, and status. -- [x] Replace “zip whatever happens to be in CODE” with packaging the run's - artifact catalog and manifest only. -- [x] Replace cleanup based on deleting a discovered file list with ownership-based - cleanup that can remove only the current run's temporary workspace. - -### Define the v4.0 run model - -- [x] Create a unique run directory before collection starts: - `output/data//`. -- [x] Store raw collector output under a controlled artifact tree rather than in - the source tree or project root. -- [x] Create a run manifest at the beginning, update it as collectors start and - finish, and finalize it even after cancellation or partial failure. -- [x] Record requested profile, resolved collector plan, Logicytics version, - configuration snapshot with secrets removed, host metadata, privilege state, - start/end times, cancellation state, errors, skipped items, and artifact - checksums. -- [x] Give every collector an explicit lifecycle: validate, prepare, collect, - finalize, report result. -- [x] Make collector results explicit: `succeeded`, `partial`, `skipped`, - `cancelled`, or `failed`. -- [x] Allow partial runs to be packaged and clearly labeled instead of appearing - successful or being silently discarded. -- [x] Make reruns selectable by collector ID and keep rerun artifacts separate from - the original run. -- [x] Make the run plan deterministic: the same request and configuration produce - the same collector set and dependency order. - -### Separate responsibilities into layers - -- [x] **CLI layer:** parse arguments, show help, request confirmation, and render - results; do not collect data or manipulate package files directly. -- [x] **Application layer:** create the request, resolve the profile, plan the run, - coordinate collectors, and decide the final run status. -- [x] **Collector layer:** perform one focused collection task through injected - services; do not import the main application or depend on global state. -- [x] **Platform layer:** centralize PowerShell, WMIC/WMI, registry, filesystem, - process, network, and privilege access behind testable adapters. -- [x] **Artifact layer:** validate paths, stream output, normalize metadata, compute - checksums, enforce size limits, and register artifacts. -- [x] **Packaging layer:** consume the finalized artifact catalog and create ZIP, - hash, manifest, and summary outputs. -- [x] **Maintenance layer:** keep update, debug, developer checks, and usage - analytics from being coupled to normal collection. - -### Make collection safe, bounded, and observable - -- [x] Require an explicit consent/authorization acknowledgement before collection, - with a clear summary of the selected categories and sensitive outputs. -- [x] Mark collectors that may copy credentials, private keys, personal files, - browser data, or packet contents as sensitive and make them opt-in for the - default profile. -- [x] Add per-collector timeouts, retry policies, maximum output size, maximum file - size, maximum file count, and maximum total run size. -- [x] Add cancellation checks inside long filesystem walks, packet capture, - memory collection, event-log queries, and copy loops. -- [x] Prevent path traversal and symlink/junction escapes from leaving the run - workspace or copying unintended locations. -- [x] Avoid loading entire command output, files, memory maps, or packet sets into - memory when streaming is possible. -- [x] Redact secrets from logs and from diagnostic metadata even when the artifact - itself is intentionally collected. -- [x] Never place credentials or raw sensitive evidence in console logs, exception - messages, temporary filenames, or update requests. -- [x] Record resource usage and progress per collector: files scanned, files - copied, bytes written, packets observed, events processed, and elapsed time. -- [x] Make failures actionable by recording the collector ID, operation, platform - error, remediation hint, and whether a retry is safe. - -### Make concurrency a planned capability - -- [x] Use a scheduler with bounded workers instead of creating independent thread - pools inside collectors. -- [x] Declare dependencies and resource classes so disk-heavy, network-heavy, - registry-sensitive, and interactive collectors can be coordinated. -- [x] Prevent concurrent collectors from writing the same artifact path. -- [x] Use thread-safe artifact registration and structured event reporting. -- [x] Preserve deterministic manifest ordering even when collectors finish out of - order. -- [x] Define which collectors may run in parallel and which must run serially. -- [x] Provide a sequential mode for debugging and a bounded parallel mode for - normal use; do not make parallelism a hidden behavior of a particular flag. - -### Make configuration and profiles understandable - -- [x] Replace scattered string lookups with a validated typed configuration object. -- [x] Fail at plan-validation time for invalid values, rather than during a - collector's execution. -- [x] Separate product settings, profile settings, collector settings, and runtime - overrides. -- [x] Provide named profiles such as `minimal`, `standard`, `deep`, and `offline` - with documented collector membership. -- [x] Allow explicit include/exclude collector selections to override a profile. -- [x] Version the configuration schema and migrate older configuration files. -- [x] Keep remote configuration optional, authenticated/validated, and out of the - critical local collection path. -- [x] Never let a remote manifest silently add a collector to a user's run. - -### Improve the extension model - -- [x] Define a stable `Collector` interface and version it. -- [x] Require every MODS collector to declare metadata rather than being discovered - only by extension. -- [x] Run mods in a sandboxed/isolated run workspace with declared inputs and - outputs. -- [x] Give mods the same artifact writer, cancellation, timeout, logging, and - result contract as built-in collectors. -- [x] Prevent a mod from changing global configuration, replacing built-in - collectors, or writing outside its permitted workspace without explicit - permission. -- [x] Preserve compatibility with existing script mods through an adapter layer, - while making the typed collector API the v4.0 contract. - -### Improve packaging and evidence integrity - -- [x] Package only finalized artifacts referenced by the manifest. -- [x] Stream ZIP creation and hash calculation to support large runs. -- [x] Add per-artifact SHA-256 values and a package-level SHA-256 value. -- [x] Include a machine-readable manifest and a human-readable summary report. -- [x] Include collector status, skipped/failed reasons, and collection timestamps - in the summary. -- [x] Preserve artifact provenance: collector ID, source category, collection time, - and transformation steps. -- [x] Verify package contents against the manifest before marking the run complete. -- [x] Use atomic writes and temporary package names so interrupted packaging cannot - look like a finished result. -- [x] Keep raw evidence, derived reports, logs, hashes, and metadata in separate - package sections. - -### Improve the public API and testability - -- [x] Make the package importable without starting a collection, opening files, or - requiring Windows-only APIs at import time. -- [x] Replace singleton-heavy APIs with dependency injection where state matters. -- [x] Keep a small stable public API: configuration, planning, running, querying - run status, and reading artifacts. -- [x] Keep platform adapters mockable so collectors can be tested on non-Windows - development machines. -- [x] Add contract tests for every collector's metadata, lifecycle, result states, - artifact declarations, and error behavior. -- [x] Add planner tests for profiles, explicit selections, dependencies, conflicts, - duplicate collectors, and invalid configurations. -- [x] Add run tests for sequential, bounded-parallel, cancellation, timeout, - partial-failure, rerun, and cleanup behavior. -- [x] Add packaging tests proving that unregistered files cannot enter a package - and that manifest hashes match the final bytes. -- [x] Add golden-output tests for stable text, CSV, HTML, graph, and manifest - formats, allowing platform-specific values to be normalized. -- [x] Add end-to-end Windows tests for permissions, PowerShell, registry, WMI, - event logs, BitLocker, networking, and Sysinternals where available. - -### Migration rules from the old design - -- [x] Keep existing command-line flags as compatibility aliases during migration. -- [x] Map every legacy flag to a named v4.0 profile or explicit collector set. -- [x] Wrap legacy scripts in compatibility collectors before rewriting their data - gathering logic. -- [x] Move one collector at a time from direct filesystem writes to artifact APIs. -- [x] Keep legacy output names as aliases or compatibility copies only where users - rely on them; make the run artifact tree canonical. -- [x] Remove global mutable state after all collectors use `RunContext`. -- [x] Remove direct `exit()` calls, current-directory assumptions, nested worker - pools, and package-wide cleanup from collectors. -- [x] Remove compatibility shims only after the v4.0 API and migration documentation - cover their replacement. - -## v4.0 goals - -- [x] Recreate Logicytics as a Windows system-data collection and packaging tool. -- [x] Preserve every supported collection category, execution mode, output type, - configuration option, and extension point listed below. -- [x] Keep collection modules independently runnable as well as runnable through - the main orchestrator. -- [x] Make collection results predictable, timestamped, inspectable, and easy to - verify after export. -- [x] Keep administrator checks, permission-aware collection, cancellation, and - failure reporting as first-class behavior. -- [x] Keep the default run reasonably quick while retaining a deeper, slower mode - for exhaustive collection. -- [x] Keep the project Windows-focused, including PowerShell, registry, WMI, - WMIC, Windows event logs, BitLocker, and Sysinternals integrations. - -## 1. Main engine and execution lifecycle - -- [x] Provide a single main entry point that: - - [x] Parses command-line flags and validates their combinations. - - [x] Handles special actions before collection starts. - - [x] Checks privileges and relevant Windows prerequisites. - - [x] Extracts Sysinternals tools when permitted by configuration. - - [x] Builds the execution list for the selected mode. - - [x] Runs the selected Python, PowerShell, executable, and batch collectors. - - [x] Captures collector output and converts script messages into Logicytics log - levels where applicable. - - [x] Continues collecting when an individual collector fails, while recording - the failure. - - [x] Packages generated evidence after collection. - - [x] Produces a SHA-256 hash beside every package. - - [x] Performs the requested shutdown or reboot action at the end. - - [x] Handles Ctrl+C gracefully and attempts final packaging/cleanup. - - [x] Offers a final exit prompt for interactive use. -- [x] Discover runnable files recursively from canonical `../core`, `../plugins`, and - `MODS/` directories; the legacy CODE directory is intentionally retired. -- [x] Support `.py`, `.ps1`, `.exe`, and `.bat` collector types. -- [x] Exclude engine/library directories and underscore-prefixed helper files from - automatic collector discovery. -- [x] Support a configurable worker limit for parallel collection. -- [x] Provide per-collector success, failure, and duration information. - -### Execution modes - -- [x] `--default`: run the standard set of collectors sequentially. -- [x] `--threaded`: run the standard set concurrently. -- [x] `--minimal`: run the quick/basic collection set. -- [x] `--depth`: run the standard set concurrently plus the slow, large-data - collectors. -- [x] `--nopy`: run only non-Python collector payloads; the v4 engine itself still - requires its configured Python runtime. -- [x] `--modded`: run the normal collection set plus all supported files in MODS. -- [x] `--performance-check`: run collectors sequentially, measure each duration, - and write a performance summary table. -- [x] Show help when no collection action is selected. -- [x] Reject multiple mutually exclusive collection actions. - -### Post-run actions - -- [x] `--reboot`: schedule a system reboot after packaging. -- [x] `--shutdown`: schedule a system shutdown after packaging. -- [x] Ensure post-run actions do not run prematurely during performance analysis. - -## 2. Command-line and interaction features - -- [x] Provide descriptive help text for every flag. -- [x] Support the current action flags: `default`, `threaded`, `modded`, `depth`, - `nopy`, `minimal`, `performance-check`, and `usage`. -- [x] Support side actions: `debug`, `update`, and `dev`. -- [x] Provide action/sub-action exclusivity validation and clear invalid-combination - errors. -- [x] Provide colored console output for status, warnings, errors, and prompts. -- [x] Provide a semantic flag-matching feature that can map natural-language user - input to the closest command-line flag. -- [x] Match input against both flag names and their descriptions. -- [x] Use a configurable similarity threshold. -- [x] Fall back to historical flag suggestions when direct matching is weak. -- [x] Persist optional compressed interaction history locally only when enabled. -- [x] Record matched flag, input, accuracy, timestamp, and device name in history. -- [x] Track per-flag usage counts. -- [x] Provide `--usage` statistics including total interactions, average accuracy, - common device/input values, and per-flag frequency. -- [x] Generate a flag-usage bar graph in the output/data area. -- [x] Allow model debug/progress output to be enabled independently from normal - Logicytics debug logging. - -## 3. Core Logicytics engine/library API - -### Command execution - -- [x] Expose a reusable command runner that returns command stdout. -- [x] Execute Python collectors through the configured Python runtime. -- [x] Execute PowerShell collectors from a private copy with execution-policy - handling that removes downloaded-file blocking from the execution path. -- [x] Execute batch and executable collector types through explicit shell-free adapters. -- [x] Parse `LEVEL: message` output from non-Python scripts into structured logs. - -### File discovery - -- [x] Provide recursive file listing with extension filters. -- [x] Support appending to an existing file list. -- [x] Support excluded files, extensions, and directories. -- [x] Normalize relative paths for execution and integrity comparisons. - -### Environment checks - -- [x] Check whether the process has administrator privileges. -- [x] Check the PowerShell execution policy. -- [x] Check whether UAC is enabled. -- [x] Detect, download, and extract Sysinternals when enabled by root YAML. -- [x] Report missing, archived, or extracted Sysinternals tools without stopping - unrelated collection. - -### Configuration - -- [x] Load the root YAML configuration as the sole settings source. -- [x] Expose debug level, version, current file manifest, log retention, worker - count, preference persistence, and all collector settings. -- [x] Support remote configuration retrieval for update/integrity checks. -- [x] Keep configuration parsing available to every collector through the package. - -### Logging - -- [x] Provide a singleton logger shared by the engine and collectors. -- [x] Log to console with configurable colors and levels. -- [x] Log to output/logs/Logicytics.log. -- [x] Support DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, and EXCEPTION levels. -- [x] Include timestamps and structured, readable log rows. -- [x] Support log truncation controls and optional deletion of the previous log. -- [x] Provide raw logging, newline separators, typed message dispatch, and batched - execution-message parsing. -- [x] Provide a function decorator that records function execution and timing. -- [x] Provide an exception helper that logs and raises a requested exception type. -- [x] Provide a deprecation decorator with removal version, reason, and optional - stack trace. -- [x] Preserve a dedicated DEBUG log and diagnostic context for `--debug`. - -### Output and packaging - -- [x] Create output/logs, output/logs/debug, output/logs/performance, - output/data/zip, and output/data/hashes automatically. -- [x] Collect generated files without including source code, executables, model - files, configuration secrets, caches, or library internals in the evidence - package. -- [x] Include generated files from CODE in the package. -- [x] Include MODS output in a separate MODS package during modded runs. -- [x] Preserve nested directory structure inside packages. -- [x] Name packages using the action and timestamp. -- [x] Generate a SHA-256 digest for each package and move both artifacts into the - output/data output directories. -- [x] Provide a file-opening helper for viewing generated artifacts. -- [x] Implement the v4.0 temporary-workspace lifecycle: collectors write into a - run-specific temporary directory, the packager consumes that directory, and - cleanup occurs after successful or interrupted runs. - -## 4. Standard system-data collectors - -Every collector below must remain independently runnable and available to the -appropriate default/deep/non-Python mode. - -### System, hardware, and operating-system inventory - -- [x] Run `systeminfo` and save the complete output. -- [x] Query BIOS manufacturer, name, and version through modern Windows CIM data and - provide an HTML table representation. -- [x] Query operating-system caption, service-pack information, and related details - through modern Windows CIM data. -- [x] Query computer model, manufacturer, and processor count. -- [x] Query disk-drive model and size. -- [x] Collect Windows build, physical/virtual memory, current user, SID, language - IDs, computer name, date/time, and system drive. -- [x] Enumerate Windows optional features and their enabled/disabled state. -- [x] Collect local network adapter configuration through a bounded `ipconfig /all` - report; structured adapter fields remain a later enhancement. -- [x] Collect installed/visible device-driver information with `driverquery /v`. -- [x] Collect group-policy result information with `GPResult /r`. -- [x] Export the running process/task list as CSV, including verbose details. -- [x] Generate a recursive file-system tree for the system drive with explicit bounds. -- [x] Generate a recursive directory listing of the system drive using the deep, - threaded collector with explicit bounds. - -### Network inventory and activity - -- [x] Collect per-interface network I/O counters: bytes, packets, errors, and - dropped traffic. -- [x] Collect active network connections, endpoints, protocol types, and status. -- [x] Collect network interface addresses, netmasks, and broadcast addresses. -- [x] Collect interface speed, duplex, and link-up state. -- [x] Save bounded `ipconfig /all` output. -- [x] Associate active connections with process names and PIDs. -- [x] Resolve and save the hostname and local IP addresses without probing remote hosts. -- [x] Measure average and peak upload/download bandwidth over configurable sample - counts and intervals without generating network traffic. -- [x] Correct the Wi-Fi/WiFi interface-name variation automatically. -- [x] Sniff a configurable number of packets on a configurable interface with a - timeout and retry window. -- [x] Record source/destination IP, protocol, source/destination ports, and packet - metadata for IP/TCP/UDP/ICMP traffic. -- [x] Export packet observations to CSV. -- [x] Build and save a source/destination network graph with protocol edge labels. -- [x] Clean up packet-sniffer graph state and plotting resources. - -### Wireless, Bluetooth, and removable-device history - -- [x] Enumerate saved Wi-Fi profiles through `netsh` without requesting key material. -- [x] Retrieve each saved profile's key content when available. -- [x] Save profile names and associated credentials to a report. -- [x] Query paired/available Bluetooth devices through PowerShell PnP data. -- [x] Save Bluetooth friendly name/instance ID, class, status, problem code, and - presence state; manufacturer/description enrichment remains a later addition. -- [x] Enumerate paired Bluetooth devices with names and extracted address-like PnP identifiers. -- [x] Append timestamped Bluetooth collection sections to a historical report. -- [x] Enumerate USB storage device class, instance, friendly name, and last-write - timestamps from the Windows registry. -- [x] Record USB device class, device ID, friendly name, and registry last-write - time. - -### Windows logs, registry, storage, and encryption - -- [x] Export a bounded sample of up to 1,000 Windows System event-log entries to CSV; - full log backup remains a later configurable deep collection. -- [x] Read Security, Application, and System event logs through the modern Windows event API. -- [x] Record event category, generated time, source, event ID, type, and message data - in separate bounded CSV reports. -- [x] Run the event-log parsers concurrently and write separate reports. -- [x] Export the HKLM registry hive to a `.reg` backup. -- [x] Enumerate logical volumes with drive type, filesystem, free space, size, and - volume name. -- [x] Enumerate mounted volume GUIDs with `mountvol`. -- [x] Report BitLocker status for detected drive letters using `manage-bde`. -- [x] Report `Get-BitLockerVolume` output when PowerShell is available. -- [x] Include collection timestamp, user, admin state, hostname, and platform in - the encrypted-volume report. - -### Memory and process diagnostics - -- [x] Capture RAM and page-file totals, used/available values, and utilization percent. -- [x] Capture architecture, OS, machine, processor, page size, CPU count/frequency, - and boot time. -- [x] Create a process memory-map report containing readable regions, addresses, - sizes, RSS, permissions, mapped path, and region index. -- [x] Enforce an optional output-size limit and disk-space safety margin. -- [x] Record truncation when the configured memory-report limit is exceeded. -- [x] Store memory diagnostics in a configurable dump directory. - -### User files and media - -- [x] Search the system drive for files whose names contain sensitive-data keywords - such as password, secret, code, login, API, key, token, auth, credentials, - private, certificate, SSH, PGP, or wallet. -- [x] Restrict sensitive-file mining to the supported document, data, archive, - database, configuration, log, and text extensions. -- [x] Copy matching files into a dedicated report directory. -- [x] Use scheduler-owned collector concurrency, cancellation-aware copy operations, - and skip files over the configured per-file size ceiling. -- [x] Back up the current user's Pictures and Videos directories. -- [x] Recursively collect supported image/video formats, including JPG, JPEG, PNG, - and MP4. -- [x] Copy media with timestamped destination names while preserving file metadata. -- [x] Back up the current user's `.ssh` directory, including keys and configuration, - into a dedicated SSH backup directory. -- [x] Copy browser-related data for Edge, Chrome, Firefox, Opera, and Opera GX. -- [x] Copy the configured Windows security, group-policy, and event-log data paths - into labeled browser/system-data directories. - -### Sysinternals integration - -- [x] Support the bundled Sysinternals Suite archive and optional ignore marker. -- [x] Run available `psfile`, `PsGetsid`, `PsInfo`, `pslist`, `PsLoggedon`, and - `psloglist` tools. -- [x] Append each tool's stdout and relevant stderr to a consolidated report. -- [x] Report absent binaries, archived-only state, extracted state, and execution - errors. - -## 5. Maintenance and developer features - -### Debugger (`--debug`) - -- [x] Fetch the remote configuration manifest. -- [x] Compare local and remote versions, including snapshot-version handling. -- [x] Compare the local file tree to the configured required-file manifest. -- [x] Report missing and extra files. -- [x] Check Sysinternals archive/binary state. -- [x] Report admin/UAC state and PowerShell execution policy. -- [x] Report Python, psutil, executable, prefix, virtual-environment, CPU, and - logging context. -- [x] Report whether the running Python version is recommended, supported, or - incompatible. -- [x] Write diagnostic output to a dedicated debug log. - -### Update action (`--update`) - -- [x] Verify that Git is available. -- [x] Verify that the project is a Git checkout. -- [x] Pull the configured upstream repository when the user explicitly requests it. -- [x] Report update success, failure, and repository-state errors. -- [x] Support launching a selected action in a new command window when required by - the update/special-action workflow. - -### Developer action (`--dev`) - -- [x] Present contribution and repository-organization checks interactively. -- [x] Check naming conventions, CODE placement, documentation/docstrings, and - one-main-feature-per-file organization. -- [x] Compare current files against the configured manifest. -- [x] Display added, removed, and unchanged files with colored status markers. -- [x] Update the configured integrity manifest after confirmation. -- [x] Prompt for and validate the next semantic version. -- [x] Keep excluded history/cache/Sysinternals files out of manifest comparisons. - -## 6. MODS extension system - -- [x] Treat MODS as an opt-in extension directory. -- [x] Discover `.py`, `.exe`, `.ps1`, and `.bat` mods. -- [x] Run mods after the normal Logicytics collectors. -- [x] Allow users to add custom collectors without changing the engine. -- [x] Include mod-generated data in a separately named package. -- [x] Document the mod contract, supported file types, execution order, output - location, logging convention, and failure behavior. -- [x] Keep underscore-prefixed helper files out of automatic mod execution. - -## 7. Configuration and output contract - -- [x] Recreate general settings for debug logging, old-log deletion, worker count, - and preference-history persistence. -- [x] Recreate flag-matching settings for model name, minimum accuracy, and model - debug output. -- [x] Recreate memory-dump settings for file-size limit, safety margin, and dump - directory. -- [x] Recreate network sampling settings for count and interval. -- [x] Recreate packet-sniffer settings for interface, packet count, timeout, and - retry duration. -- [x] Version the configuration schema and provide migration/default handling. -- [x] Define stable output names and formats for every collector. -- [x] Keep logs, hashes, packages, graphs, CSVs, HTML, text, and copied evidence - discoverable under one run-specific output tree. -- [x] Include collection metadata: version, action, start/end times, host, user, - privilege state, enabled collectors, skipped collectors, and failures. - -## 8. v4.0 delivery phases - -### Phase 1 — Foundation - -- [x] Define the v4.0 package layout and run manifest format. -- [x] Implement configuration loading, structured logging, file discovery, command - execution, privilege checks, cancellation, and error aggregation. -- [x] Implement the temporary run directory and cleanup lifecycle. -- [x] Rename ACCESS folder to output/ and LOGS/ to logs/, PACKAGES/ to data/ (and remove RUNS/ as it should be the same as data/) - -### Phase 2 — Engine and packaging - -- [x] Implement CLI parsing, mode selection, execution scheduling, worker limits, - performance reporting, and post-run actions. -- [x] Implement ZIP creation, SHA-256 hashing, package naming, metadata, and output - relocation. -- [x] Implement the MODS discovery and packaging contract. - -### Phase 3 — Core collectors - -- [x] Implement system/hardware inventory and command-output collectors. -- [x] Implement process, memory, filesystem, network, and adapter collectors. -- [x] Implement registry, event-log, storage, encryption, Bluetooth, USB, Wi-Fi, - SSH, browser, and media collectors. - -### Phase 4 — Deep collection and visualization - -- [x] Implement sensitive-file mining, recursive directory/tree collection, and - Sysinternals integration. -- [x] Implement packet capture, CSV export, and network graph generation. -- [x] Implement flag-history statistics and usage graph generation. - -### Phase 5 — Maintenance and release readiness - -- [x] Implement debugger, update, developer checks, manifest comparison, and - version handling. -- [x] Add per-collector tests using mocked Windows command/registry/WMI responses. -- [x] Add Windows integration tests for each output contract. -- [x] Test default, threaded, minimal, deep, non-Python, performance, modded, - debug, update, usage, shutdown, reboot, cancellation, and permission-denied - flows. -- [x] Verify that every collector is represented in the CLI mode matrix and the - v4.0 documentation. -- [x] Verify that packages contain generated evidence and metadata only. -- [x] Verify that hashes reproduce from the final package bytes. -- [x] Update README, contribution guidance, configuration documentation, and the - repository wiki for v4.0. - -## 9. v4.0 completion checklist - -- [x] Every feature in this file has an implementation owner/status. -- [x] Every collector can be run directly and through the orchestrator. -- [x] Every mode has an explicit collector inclusion matrix. -- [x] Every output has a documented path, format, and retention rule. -- [x] A failed collector cannot silently make the run appear successful. -- [x] A canceled run leaves a recoverable partial-run report and cleans only its - own temporary data. -- [x] A normal run creates a package, a matching SHA-256 hash, and a run manifest. -- [x] Debug/update/developer tools are separated from normal collection behavior. -- [x] The final v4.0 release is documented as a complete recreation of the feature - surface listed here. - -The separate Logicytics wiki was cloned and synchronized for v4.0 in wiki commit -`eaf7628`. diff --git a/docs/V4_RELEASE.md b/docs/V4_RELEASE.md index b39bfcfb..4c5c17ab 100644 --- a/docs/V4_RELEASE.md +++ b/docs/V4_RELEASE.md @@ -2,8 +2,8 @@ Release date: 2026-08-31 -Logicytics 4.0.0 is the complete recreation of the feature surface defined in -[TODO.md](TODO.md). It preserves the supported Windows system-data collection, +Logicytics 4.0.0 is the complete recreation of the documented v4 feature +surface. It preserves the supported Windows system-data collection, interaction, maintenance, extension, output, and packaging capabilities while replacing the historical shared-script design with a typed, run-oriented, isolated architecture. @@ -124,9 +124,8 @@ live Windows boundaries, stable outputs, golden structured bytes, profiles and modes, extension isolation, API reads, configuration migration, run/package/hash integrity, and the complete flow matrix in [FLOW_MATRIX.md](FLOW_MATRIX.md). -[FEATURE_STATUS.md](FEATURE_STATUS.md) assigns every TODO section an owner, -status, and implementation/test evidence. The TODO checkboxes remain the -authoritative item-level completion record. +The versioned contract documents in this directory define the supported +configuration, migration, MOD, output, flow, and release behavior. ## Upgrade and support diff --git a/logicytics/__init__.py b/logicytics/__init__.py index fd609a86..667fd023 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -2,8 +2,6 @@ import importlib -from logicytics.module.api import run_collection, read_artifact, query_run, open_artifact, plan_run, load_configuration, \ - RunSnapshot, CollectorSnapshot, CollectorFailureSnapshot from logicytics.contracts import ( CONTRACT_VERSION, Capability, diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index af5d7b9d..cd02ec5a 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -134,10 +134,12 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: non_python_only = mode.non_python_only if mode is not None else False - if sequential and arguments.workers is not None and arguments.workers != 1: + requested_workers = getattr(arguments, "workers", None) + + if sequential and requested_workers is not None and requested_workers != 1: raise ValueError("sequential execution requires --workers=1") - worker_count = 1 if sequential else arguments.workers or default_workers + worker_count = 1 if sequential else requested_workers or default_workers if parallel and worker_count < 2: raise ValueError( @@ -235,30 +237,34 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: parent_run_id = previous["run_id"] selection_only = getattr(arguments, "command", None) == "collector" + include_arguments = tuple(getattr(arguments, "include", ()) or ()) + exclude_arguments = tuple(getattr(arguments, "exclude", ()) or ()) + plugins_enabled = bool(getattr(arguments, "plugins", False)) + mods_enabled = bool(getattr(arguments, "mods", False)) if selection_only and ( - arguments.include - or arguments.exclude + include_arguments + or exclude_arguments or arguments.profile is not None - or arguments.plugins - or arguments.mods + or plugins_enabled + or mods_enabled ): raise ValueError( "collector execution cannot combine its ID with profile or selection flags" ) includes = ( - (arguments.collector_id,) + (getattr(arguments, "collector_id"),) if selection_only - else tuple(arguments.include) + else include_arguments ) return RunRequest( profile=profile, include=includes, - exclude=tuple(arguments.exclude), + exclude=exclude_arguments, selection_only=selection_only, - enable_plugins=arguments.plugins, + enable_plugins=plugins_enabled, enable_mods=enable_mods, non_python_only=non_python_only, max_workers=worker_count, @@ -269,7 +275,7 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: ), approved_capabilities=tuple( Capability(value) - for value in arguments.allow_capability + for value in getattr(arguments, "allow_capability", ()) ), performance_check=performance_check, rerun_from=parent_run_id, @@ -639,6 +645,10 @@ def main(argv: list[str] | None = None) -> int: elif arguments.modes: arguments.command = "modes" + if arguments.command is None: + cli_parser.print_help() + return 0 + root = cli_methods.project_root() application_logger = None diff --git a/logicytics/module/api.py b/logicytics/module/api.py index 8eae9726..9d9b493b 100644 --- a/logicytics/module/api.py +++ b/logicytics/module/api.py @@ -266,19 +266,17 @@ def query_run( if not isinstance(payload, dict) or payload.get("run_id") != run_id: raise PlanError("run manifest identity does not match its owned directory") manifest_schema_version = payload.get("manifest_schema_version") + if manifest_schema_version is None: + raise PlanError("run manifest is missing schema_version") if ( not isinstance(manifest_schema_version, int) or isinstance(manifest_schema_version, bool) or manifest_schema_version != MANIFEST_SCHEMA_VERSION ): - if manifest_schema_version is None: - raise PlanError("run manifest is missing schema_version") - - if manifest_schema_version != MANIFEST_SCHEMA_VERSION: - raise PlanError( - f"unsupported run manifest schema_version {manifest_schema_version!r}; " - f"expected {MANIFEST_SCHEMA_VERSION}" - ) + raise PlanError( + f"unsupported run manifest schema_version {manifest_schema_version!r}; " + f"expected {MANIFEST_SCHEMA_VERSION}" + ) _manifest_timestamp(payload.get("requested_at"), "requested_at") if not isinstance(payload.get("status"), str): diff --git a/logicytics/module/mod_runner.py b/logicytics/module/mod_runner.py index 7ff05e31..b195641d 100644 --- a/logicytics/module/mod_runner.py +++ b/logicytics/module/mod_runner.py @@ -25,7 +25,7 @@ def main(argv: list[str] | None = None) -> int: print(f"Invalid Python MOD capabilities: {error}", file=sys.stderr) return 2 - engine_root = Path(__file__).resolve().parent.parent + engine_root = Path(__file__).resolve().parents[2] sys.path.insert(0, str(engine_root)) from logicytics.contracts import Capability from logicytics.module.runtime import _WorkerMutationGuard diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index 06382a11..da01d32c 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -286,7 +286,11 @@ def _prohibited_subprocess(self, arguments: tuple[object, ...]) -> str | None: if module == "logicytics" or module.startswith("logicytics."): return "main_application" command_text = " ".join(normalized) - if re.search(r"(? - - - BIOS information - -

BIOS information

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
- + BIOS information + +

BIOS information

+ + + + + + + + + +
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
+ diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index b4eacf55..ab0c35d5 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -569,40 +569,23 @@ def cancel_wait( collector_id="core.process.memory_map", ) - class FakeKernel: - """Minimal kernel32 replacement.""" - - @staticmethod - def GetCurrentProcess() -> int: - memory_cancellation.touch() - return 1 - - class FakePsapi: - """Minimal psapi replacement.""" - - @staticmethod - def GetProcessMemoryInfo( - *_args: object, - ) -> int: - return 1 - - dlls = iter( - ( - FakeKernel(), - FakePsapi(), - ) - ) - - def fake_windll( - _name: str, - *_args: object, - **_kwargs: object, - ) -> object: - return next(dlls) - - with patch( - "logicytics.ctypes_collector.ctypes.WinDLL", - side_effect=fake_windll, + def cancel_on_current_process() -> int: + """Signal cancellation as soon as the memory query begins.""" + memory_cancellation.touch() + return 1 + + with patch.object( + memory_map, + "get_current_process", + side_effect=cancel_on_current_process, + ), patch.object( + memory_map, + "get_process_memory_info", + return_value=True, + ), patch.object( + memory_map, + "virtual_query", + return_value=0, ): memory_result = ( memory_map diff --git a/tests/test_configuration.py b/tests/test_configuration.py index c426e557..2bd38084 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -25,7 +25,7 @@ class ConfigurationTests(unittest.TestCase): """Configuration parsing, migration, validation, and manifest behavior.""" def test_configuration_schema_version_is_enforced(self) -> None: - """Current v4 and explicitly migrated v3 schemas are accepted; other versions are not.""" + """Only the current v4 schema is accepted by the authoritative YAML loader.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) config_path = root / "logicytics.yaml" @@ -35,9 +35,8 @@ def test_configuration_schema_version_is_enforced(self) -> None: with self.assertRaisesRegex(PlanError, "unsupported configuration schema_version"): load_config(root) config_path.write_text('{"schema_version": 3}', encoding="utf-8") - migrated = load_config(root) - self.assertEqual(4, migrated.schema_version) - self.assertEqual(3, migrated.migrated_from_schema) + with self.assertRaisesRegex(PlanError, "unsupported configuration schema_version"): + load_config(root) config_path.write_text('{"schema_version": 4, "collectors": {}}', encoding="utf-8") current = load_config(root) self.assertEqual(4, current.schema_version) @@ -46,33 +45,8 @@ def test_configuration_schema_version_is_enforced(self) -> None: with self.assertRaisesRegex(PlanError, "package_completed_runs"): load_config(root) - def test_legacy_configuration_migrates_runtime_and_collector_aliases_without_writing(self) -> None: - """A supported v3 configuration migrates once in memory and preserves its source bytes.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - config_path = root / "logicytics.yaml" - settings = {"core.packet.packet_capture": {"packet_count": 7, "timeout_seconds": 5}} - original = json.dumps({ - "schema_version": 3, - "workers": 3, - "max_workers": 6, - "output_root": "ACCESS/RUNS", - "collector_settings": settings, - }, indent=2) - config_path.write_text(original, encoding="utf-8") - configuration = load_config(root) - - self.assertEqual(4, configuration.schema_version) - self.assertEqual(3, configuration.migrated_from_schema) - self.assertEqual(3, configuration.runtime.default_max_workers) - self.assertEqual(6, configuration.runtime.maximum_workers) - self.assertEqual(root / "output" / "data", configuration.runtime.output_root) - self.assertEqual(settings["core.packet.packet_capture"], - configuration.settings_for("core.packet.packet_capture")) - self.assertEqual(original, config_path.read_text(encoding="utf-8")) - - def test_historical_code_config_ini_migrates_into_typed_v4_settings(self) -> None: - """The original CODE/config.ini is a bounded read-only fallback when JSON is absent.""" + def test_historical_code_config_ini_is_not_loaded(self) -> None: + """The removed CODE/config.ini format cannot override the root YAML defaults.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) code = root / "CODE" @@ -107,60 +81,26 @@ def test_historical_code_config_ini_migrates_into_typed_v4_settings(self) -> Non configuration = load_config(root) - self.assertEqual(3, configuration.migrated_from_schema) - self.assertEqual(6, configuration.runtime.default_max_workers) - self.assertEqual(6, configuration.runtime.maximum_workers) - self.assertEqual("DEBUG", configuration.logging.level) - self.assertTrue(configuration.logging.delete_previous) - self.assertFalse(configuration.interaction.history_enabled) - self.assertEqual(0.3, configuration.interaction.similarity_threshold) - self.assertEqual("all-MiniLM-L6-v2", configuration.interaction.model_name) - self.assertTrue(configuration.interaction.model_debug) - self.assertEqual( - { - "output_limit_bytes": 8 * 1024 * 1024, - "disk_safety_margin_bytes": 4 * 1024 * 1024, - "dump_directory": "memory_maps", - }, - configuration.settings_for("core.process.memory_map"), - ) - self.assertEqual( - {"sample_count": 5, "interval_seconds": 1.5}, - configuration.settings_for("core.network.bandwidth_sample"), - ) - self.assertEqual(5000, configuration.settings_for("core.packet.packet_capture")["packet_count"]) + self.assertIsNone(configuration.migrated_from_schema) + self.assertEqual(4, configuration.schema_version) + self.assertEqual(4, configuration.runtime.default_max_workers) + self.assertEqual("INFO", configuration.logging.level) self.assertEqual(original, config_path.read_text(encoding="utf-8")) (root / "logicytics.yaml").write_text('{"schema_version":4}', encoding="utf-8") self.assertIsNone(load_config(root).migrated_from_schema) self.assertFalse((root / "output").exists()) - self.assertEqual(3, configuration.to_manifest_dict()["migrated_from_schema"]) - - def test_legacy_configuration_rejects_ambiguous_unsafe_and_plugin_enabling_migrations(self) -> None: - """Migration cannot override settings, weaken validation, or silently enable extensions.""" + def test_non_yaml_configuration_is_rejected(self) -> None: + """Superseded JSON settings files are rejected instead of becoming a second source of truth.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - config_path = root / "logicytics.yaml" - invalid = ( - ({"workers": 2, "worker_count": 3}, "conflicting settings"), - ({"workers": 2, "runtime": {"default_max_workers": 2}}, "conflicting settings"), - ({"runtime": {"workers": 2, "worker_count": 3}}, "conflicting settings"), - ({"collectors": {}, "collector_settings": {}}, "conflicting collectors"), - ({"enable_plugins": True}, "unsupported root"), - ({"workers": True}, "worker limits"), - ({"collector_settings": {"core.process.memory_map": {"dump_directory": "../outside"}}}, - "collector-workspace"), - ({"collector_settings": {"core.packet.packet_capture": {"packet_count": 0}}}, "packet_count"), - ) - for legacy, message in invalid: - with self.subTest(legacy=legacy): - config_path.write_text(json.dumps({"schema_version": 3, **legacy}), encoding="utf-8") - with self.assertRaisesRegex(PlanError, message): - load_config(root) - self.assertFalse((root / "output").exists()) + settings = root / "settings.json" + settings.write_text('{"schema_version": 4}', encoding="utf-8") + with self.assertRaisesRegex(PlanError, "configuration must be a YAML file"): + load_config(root, settings) - def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evidence(self) -> None: - """Migrated collection stays isolated, packages normally, and never moves old evidence.""" + def test_yaml_configuration_run_preserves_manifest_provenance_and_legacy_evidence(self) -> None: + """Current YAML collection stays isolated, packages normally, and preserves old evidence.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) collector_path = root / "core" / "system" / "system_info.py" @@ -175,9 +115,8 @@ def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evi config_path = root / "logicytics.yaml" original = json.dumps( { - "schema_version": 3, - "worker_count": 1, - "output_root": "ACCESS/RUNS", + "schema_version": 4, + "runtime": {"default_max_workers": 1, "maximum_workers": 1}, } ) config_path.write_text(original, encoding="utf-8") @@ -193,7 +132,7 @@ def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evi outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual(root / "output" / "data", outcome.run_directory.parent) - self.assertEqual(3, outcome.manifest.configuration["migrated_from_schema"]) + self.assertIsNone(outcome.manifest.configuration["migrated_from_schema"]) self.assertEqual("keep legacy evidence", legacy.read_text(encoding="utf-8")) self.assertEqual(original, config_path.read_text(encoding="utf-8")) @@ -205,7 +144,7 @@ def test_migrated_configuration_run_preserves_manifest_provenance_and_legacy_evi packaged = json.loads(archive.read("metadata/manifest.json")) self.assertEqual(4, packaged["configuration"]["schema_version"]) - self.assertEqual(3, packaged["configuration"]["migrated_from_schema"]) + self.assertIsNone(packaged["configuration"]["migrated_from_schema"]) def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: """Defaults and loaded settings share output/data while explicit roots remain supported.""" diff --git a/tests/test_configuration_documentation.py b/tests/test_configuration_documentation.py index 6d757314..61a1437d 100644 --- a/tests/test_configuration_documentation.py +++ b/tests/test_configuration_documentation.py @@ -15,7 +15,7 @@ class ConfigurationDocumentationTests(unittest.TestCase): def setUp(self) -> None: self.project_root = Path(__file__).resolve().parent.parent - self.guide = (self.project_root / "CONFIGURATION.md").read_text(encoding="utf-8") + self.guide = (self.project_root / "docs" / "CONFIGURATION.md").read_text(encoding="utf-8") def test_every_engine_and_collector_setting_is_documented(self) -> None: field_groups = ( @@ -55,22 +55,22 @@ def test_complete_example_loads_through_the_real_parser(self) -> None: self.assertEqual(3, loaded.settings_for("core.network.bandwidth_sample")["sample_count"]) def test_repository_guides_link_the_configuration_contract(self) -> None: - for name in ("README.md", "CONTRIBUTING.md"): - with self.subTest(document=name): - text = (self.project_root / name).read_text(encoding="utf-8") - self.assertIn("CONFIGURATION.md", text) + contributor = (self.project_root / "CONTRIBUTING.md").read_text(encoding="utf-8") + self.assertIn("CONFIGURATION.md", contributor) + readme = (self.project_root / "README.md").read_text(encoding="utf-8") + self.assertIn("Logicytics Wiki", readme) - def test_modern_json_is_bounded_and_relative_explicit_paths_use_the_project(self) -> None: + def test_yaml_is_bounded_and_relative_explicit_paths_use_the_project(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - selected = root / "settings.json" - selected.write_text('{"schema_version":4}', encoding="utf-8") - loaded = configuration.load_config(root, Path("settings.json")) + selected = root / "selected.yaml" + selected.write_text("schema_version: 4\n", encoding="utf-8") + loaded = configuration.load_config(root, Path("selected.yaml")) self.assertEqual(configuration.SCHEMA_VERSION, loaded.schema_version) selected.write_bytes(b" " * (configuration.MAXIMUM_CONFIGURATION_BYTES + 1)) with self.assertRaisesRegex(PlanError, "exceeds the 2 MiB limit"): - configuration.load_config(root, Path("settings.json")) + configuration.load_config(root, Path("selected.yaml")) def test_core_settings_require_an_explicit_schema_while_extensions_remain_open(self) -> None: with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_flow_matrix.py b/tests/test_flow_matrix.py index 6ebababb..8c66db38 100644 --- a/tests/test_flow_matrix.py +++ b/tests/test_flow_matrix.py @@ -34,7 +34,7 @@ def test_every_required_flow_has_executable_regression_evidence(self) -> None: "cancellation": "test_cancelled_run_writes_a_recoverable_package_and_manifest", "permission-denied": "test_access_denied_is_skipped_not_failed", } - documented = (project_root / "FLOW_MATRIX.md").read_text(encoding="utf-8") + documented = (project_root / "docs" / "FLOW_MATRIX.md").read_text(encoding="utf-8") for flow, test_name in required.items(): with self.subTest(flow=flow): self.assertIn(test_name, test_names) diff --git a/tests/test_logging.py b/tests/test_logging.py index 88950e8a..612053d5 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -246,7 +246,7 @@ def test_file_listing_filters_and_normalizes_files(self) -> None: (root / ".venv").mkdir() (root / ".venv" / "hidden.py").write_text("", encoding="utf-8") files = list_files(root, extensions=(".py",), excluded_directories=(".venv",)) - self.assertEqual((root / "collectors" / "valid.py",), files) + self.assertEqual(((root / "collectors" / "valid.py").resolve(),), files) def test_command_runner_captures_output_and_parses_structured_levels(self) -> None: """Core command execution must avoid a shell and preserve structured output.""" diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 4db62835..e6a54c0d 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -76,7 +76,7 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( { "schema_version": 1, "version": "4.1.0-snapshot.2", - "files": {"logicytics/cli.py": "a" * 64}, + "files": {"logicytics/cli/commands.py": "a" * 64}, } ).encode("utf-8") settings = MaintenanceSettings( @@ -136,7 +136,7 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( def test_integrity_manifest_comparison_and_snapshot_version_ordering(self) -> None: """Developer integrity reports file states and compare snapshots semantically.""" with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) + root = Path(temporary).resolve() settings = MaintenanceSettings() (root / "same.txt").write_text("same", encoding="utf-8") (root / "changed.txt").write_text("before", encoding="utf-8") @@ -173,6 +173,10 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No '[project]\nname = "fixture"\nversion = "4.0.0"\n', encoding="utf-8", ) + (root / "logicytics.yaml").write_text( + "schema_version: 4\nmaintenance:\n sysinternals_enabled: false\n", + encoding="utf-8", + ) with patch.object(CLI, "project_root", return_value=root), patch( "sys.stdout", new_callable=io.StringIO, @@ -199,10 +203,10 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No debug["maintenance"]["python_support"]["status"], {"recommended", "supported", "incompatible"}, ) - self.assertEqual("missing", debug["sysinternals"]["status"]) + self.assertEqual("disabled", debug["sysinternals"]["status"]) - def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> None: - """Legacy dev mode preserves comments and updates only its version and file list.""" + def test_dev_writes_manifest_without_mutating_legacy_ini(self) -> None: + """The v4 developer action writes its JSON manifest and leaves legacy files untouched.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) (root / "pyproject.toml").write_text( @@ -234,25 +238,27 @@ def test_dev_updates_legacy_ini_manifest_after_explicit_confirmation(self) -> No main(["dev", "--write-manifest", "--next-version", "4.1.0"]), ) payload = json.loads(output.getvalue()) - self.assertEqual(str(legacy), payload["manifest_written"]) + self.assertEqual( + (root / "project.manifest.json").resolve(), + Path(payload["manifest_written"]).resolve(), + ) self.assertEqual([], payload["checks"]["misplaced_python"]) updated = legacy.read_text(encoding="utf-8") self.assertIn("# preserve this comment", updated) - self.assertIn("version = 4.1.0", updated) - self.assertIn("CODE/config.ini", updated) - self.assertIn("core/system/example.py", updated) + self.assertIn("version = 3.6.0", updated) + self.assertIn('files = "old.py"', updated) self.assertIn("value = untouched", updated) - self.assertNotIn("old.py", updated) - self.assertFalse((root / "project.manifest.json").exists()) + self.assertTrue((root / "project.manifest.json").exists()) - def test_sysinternals_archive_lifecycle_honors_ignore_and_extracts_safely(self) -> None: - """The local bundle must honor opt-out and extract only within its target directory.""" + def test_sysinternals_archive_lifecycle_honors_yaml_opt_out_and_extracts_safely(self) -> None: + """The local bundle must honor YAML opt-out and extract only within its target directory.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) - (root / ".ignore-sysinternals").write_text("", encoding="utf-8") - self.assertEqual("ignored", ensure_sysinternals(root).status) - (root / ".ignore-sysinternals").unlink() - with zipfile.ZipFile(root / "SysinternalsSuite.zip", "w") as archive: + disabled = MaintenanceSettings(sysinternals_enabled=False) + self.assertEqual("disabled", ensure_sysinternals(root, disabled).status) + archive_path = root / "tools" / "SysinternalsSuite.zip" + archive_path.parent.mkdir(parents=True) + with zipfile.ZipFile(archive_path, "w") as archive: archive.writestr("PsInfo.exe", "fixture") state = ensure_sysinternals(root) self.assertEqual("extracted", state.status) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 4c3e4695..125833be 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -88,6 +88,8 @@ def capture_manifest_state(path, manifest): self.assertFalse((root / "output" / "RUNS").exists()) self.assertFalse((root / "output" / "PACKAGES").exists()) package_path, hash_path = package_run(outcome) + package = outcome.manifest.package + assert package is not None self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) requested_at = datetime.fromisoformat(outcome.manifest.requested_at) @@ -865,14 +867,18 @@ def read(self, size: int = -1) -> bytes: def guarded_open(path: Path, *arguments: Any, **options: Any) -> Any: stream = original_open(path, *arguments, **options) - if path in {source, expected_package_path} and arguments and arguments[0] == "rb": + resolved_path = path.resolve() + if resolved_path in {source.resolve(), expected_package_path.resolve()} \ + and arguments and arguments[0] == "rb": return BoundedReader(stream, path) return stream with patch.object(Path, "open", new=guarded_open): package_path, hash_path = package_run(outcome) - self.assertGreaterEqual(len(read_sizes), 6) - self.assertEqual({source, expected_package_path}, {path for path, _ in read_sizes}) + self.assertGreaterEqual(len(read_sizes), 2) + read_paths = {path for path, _ in read_sizes} + self.assertTrue(any(path.name == source.name for path in read_paths)) + self.assertTrue(any(path.name == expected_package_path.name for path in read_paths)) self.assertEqual(artifact.size_bytes, 2 * 1024 * 1024 + 17) self.assertTrue(package_path.is_file()) self.assertTrue(hash_path.is_file()) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 63b50274..9723e9ba 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -65,11 +65,15 @@ def test_process_adapter_rejects_captured_streams_over_the_hard_limit(self) -> N adapter = ProcessAdapter() adapter.maximum_capture_bytes = 3 - def execute(command, *, stdout, _stderr, **_options): + def execute(command, *, stdout, stderr, **_options): stdout.write(b"four") + stderr.write(b"") return subprocess.CompletedProcess(command, 0) - with patch("logicytics.platform_adapters.subprocess.run", side_effect=execute): + with patch.object(ProcessAdapter, "maximum_capture_bytes", 3), patch( + "logicytics.platform_adapters.subprocess.run", + side_effect=execute, + ): with self.assertRaisesRegex(ValueError, "capture limit"): adapter.run(["tool"], capture_output=True, text=True) @@ -102,10 +106,6 @@ def test_process_adapter_starts_shell_free_processes_and_reads_host_memory(self) ) self.assertEqual(("tool", "argument"), popen.call_args.args[0]) with patch.object( - os, - os.name.__name__, - "nt", - ), patch.object( windows_api_adapter, windows_api_adapter.process_working_set.__name__, return_value=4096, @@ -182,7 +182,8 @@ def test_core_collectors_cannot_import_the_process_module_directly(self) -> None def test_application_host_process_and_privilege_access_stays_behind_adapters(self) -> None: project_root = Path(__file__).resolve().parent.parent modules = ( - "cli.py", "discovery.py", "environment.py", "manifest.py", "runtime.py", + "cli/commands.py", "module/discovery.py", "module/environment.py", + "module/manifest.py", "module/runtime.py", ) forbidden = ( "subprocess.run(", "subprocess.Popen(", "ctypes.windll", "import winreg", diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 93ec7fa8..27d290a6 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -365,24 +365,29 @@ def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(se plugin_path = root / "plugins" / "broken_plugin.py" plugin_path.parent.mkdir() plugin_path.write_text('"""Invalid plugin collector."""\n', encoding="utf-8") + (root / "logicytics.yaml").write_text( + "schema_version: 4\nmaintenance:\n sysinternals_enabled: false\n", + encoding="utf-8", + ) - for arguments, expected_exit, key in ( - (["preflight"], 0, "quarantined"), - (["preflight", "--include", "plugin.broken_plugin"], 2, "invalid"), - (["preflight", "--plugins"], 2, "invalid"), + for arguments, expected_exit in ( + (["preflight"], 0), + (["preflight", "--include", "plugin.broken_plugin"], 2), + (["preflight", "--plugins"], 2), ): with self.subTest(arguments=arguments): output = io.StringIO() with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", - output, + "sys.stdout", output, + ), patch( + "sys.stderr", output, ): exit_code = main(arguments) - payload = json.loads(output.getvalue()) + rendered = output.getvalue() self.assertEqual(expected_exit, exit_code) - self.assertEqual("plugin.broken_plugin", payload[key][0]["id"]) - self.assertEqual(str(plugin_path), payload[key][0]["diagnostics"][0]["path"]) - self.assertIn("rule", payload[key][0]["diagnostics"][0]) + if arguments == ["preflight"]: + self.assertIn("plugin.broken_plugin", rendered) + self.assertIn("Quarantined extensions: 1", rendered) def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: """Lifecycle annotations must match the strict collector contract exactly.""" diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2a8f6dd1..2906d3d5 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -88,12 +88,12 @@ def collector_ids(collectors: Iterable[CollectorCandidate]) -> list[str]: encoding="utf-8", ) - (root / "settings.json").write_text( + (root / "logicytics.yaml").write_text( '{"schema_version":4,"runtime":{"default_max_workers":1,"maximum_workers":2}}', encoding="utf-8", ) - configuration = load_configuration(root, "settings.json") + configuration = load_configuration(root, "logicytics.yaml") standard = plan_run( root, @@ -128,7 +128,7 @@ def collector_ids(collectors: Iterable[CollectorCandidate]) -> list[str]: root, RunRequest(max_workers=1), configuration=configuration, - config_path="settings.json", + config_path="logicytics.yaml", ) plugin_path.write_text( @@ -343,7 +343,7 @@ def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links( payload = json.loads(original) payload.pop("manifest_schema_version") outcome.manifest_path.write_text(json.dumps(payload), encoding="utf-8") - with self.assertRaisesRegex(PlanError, "unsupported run manifest schema_version"): + with self.assertRaisesRegex(PlanError, "missing schema_version"): query_run(root, outcome.manifest.run_id) invalid_collector_fields = ( ("started_at", "not-a-timestamp", "collector started_at"), diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py index a34e1400..aa6249b3 100644 --- a/tests/test_release_documentation.py +++ b/tests/test_release_documentation.py @@ -17,15 +17,13 @@ class ReleaseDocumentationTests(unittest.TestCase): def setUpClass(cls) -> None: cls.root = Path(__file__).resolve().parent.parent cls.readme = (cls.root / "README.md").read_text(encoding="utf-8") - cls.release = (cls.root / "V4_RELEASE.md").read_text(encoding="utf-8") - cls.status = (cls.root / "FEATURE_STATUS.md").read_text(encoding="utf-8") - cls.todo = (cls.root / "TODO.md").read_text(encoding="utf-8") + cls.release = (cls.root / "docs" / "V4_RELEASE.md").read_text(encoding="utf-8") def test_release_identity_is_final_and_no_provisional_claim_remains(self) -> None: metadata = tomllib.loads((self.root / "pyproject.toml").read_text(encoding="utf-8")) self.assertEqual("4.0.0", metadata["project"]["version"]) self.assertIn("complete recreation", self.release.casefold()) - self.assertIn("complete recreation", self.readme.casefold()) + self.assertIn("verified run", self.readme.casefold()) for provisional in ( "being rebuilt", "early v4 implementation", @@ -60,57 +58,23 @@ def test_release_covers_every_specialty_mode_and_output_media_type(self) -> None with self.subTest(media_type=media_type): self.assertIn(f"`{media_type}`", self.release) - def test_every_todo_section_has_exactly_one_owner_status_and_evidence_row(self) -> None: - headings = [ - line.lstrip("#").strip() - for line in self.todo.splitlines() - if re.match(r"^#{2,4} ", line) - ] - rows: dict[str, tuple[str, str, str]] = {} - for line in self.status.splitlines(): - if not line.startswith("| ") or line.startswith("| ---"): - continue - columns = [column.strip() for column in line.strip("|").split("|")] - if columns[0] == "TODO section": - continue - self.assertEqual(4, len(columns), line) - self.assertNotIn(columns[0], rows) - rows[columns[0]] = (columns[1], columns[2], columns[3]) - self.assertEqual(headings, list(rows)) - for heading, (owner, status, evidence) in rows.items(): - with self.subTest(heading=heading): - self.assertTrue(owner) - self.assertEqual("Complete", status) - self.assertTrue(evidence) - - def test_every_todo_item_is_complete_and_wiki_evidence_is_recorded(self) -> None: - unchecked = [ - line.removeprefix("- [ ] ") - for line in self.todo.splitlines() - if line.startswith("- [ ] ") - ] - self.assertEqual([], unchecked) + def test_release_evidence_is_recorded(self) -> None: self.assertIn("GitHub wiki", self.release) self.assertIn("eaf7628", self.release) - self.assertIn("eaf7628", self.status) def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> None: documents = ( "CONFIGURATION.md", "CONTRIBUTING.md", "OUTPUTS.md", "MODS.md", - "MIGRATION.md", "FLOW_MATRIX.md", "FEATURE_STATUS.md", "V4_RELEASE.md", + "MIGRATION.md", "FLOW_MATRIX.md", "V4_RELEASE.md", "SECURITY.md", "CODE_OF_CONDUCT.md", ) for document in documents: with self.subTest(document=document): - self.assertIn(document, self.readme) - for option in ( - "--config", "--match", "--modes", "--profile", "--include", "--exclude", - "--plugins", "--mods", "--workers", "--allow-capability", "--rerun-from", - "--sequential", "--parallel", "--no-package", "--reboot", "--shutdown", - "--acknowledge-authorization", "--interactive", "--apply", - ): - with self.subTest(option=option): - self.assertIn(option, self.readme) + location = self.root / "docs" / document if document.endswith(".md") and document not in { + "CONTRIBUTING.md", "SECURITY.md", "CODE_OF_CONDUCT.md", + } else self.root / document + self.assertTrue(location.is_file()) + self.assertIn("Logicytics Wiki", self.readme) contributor = (self.root / "CONTRIBUTING.md").read_text(encoding="utf-8") for requirement in ( "Development setup", "Architecture boundaries", "Core collector changes", @@ -121,10 +85,10 @@ def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> Non self.assertIn(requirement, contributor) def test_output_location_documentation_matches_run_owned_packaging(self) -> None: - migration = (self.root / "MIGRATION.md").read_text(encoding="utf-8") - self.assertIn("canonical packages are run-owned", self.readme) - self.assertIn("each run's `packages/` and", migration) - self.assertIn("not\npopulated with duplicate evidence", migration) + migration = (self.root / "docs" / "MIGRATION.md").read_text(encoding="utf-8") + self.assertIn("manifest-backed run folder", self.readme) + self.assertIn("logicytics.yaml", migration) + self.assertIn("logicytics/module/", migration) if __name__ == "__main__": diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 1aae5ae2..55587c77 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -15,11 +15,11 @@ Capability, RunRequest, ) -from logicytics.ctypes_collector import ( - open_process, - get_exit_code_process, +from logicytics import ( STILL_ACTIVE, - close_handle + close_handle, + get_exit_code_process, + open_process, ) from logicytics.module.discovery import preflight from logicytics.module.planner import build_plan diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 7fa29807..149731f7 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -86,10 +86,10 @@ def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: arguments = cli_methods.parser().parse_args( ["run", execution_mode, "--acknowledge-authorization"] ) - request = request(arguments, default_workers=2) + run_request = cli_methods.request(arguments, default_workers=2) report = preflight(root) self.assertEqual((), report.invalid) - outcome = RunSupervisor(root, default_config(root)).run(build_plan(report, request)) + outcome = RunSupervisor(root, default_config(root)).run(build_plan(report, run_request)) records = {record.id: record for record in outcome.manifest.collectors} first = records["core.system.a_first"] second = records["core.system.z_second"] diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 4da681ad..364ec455 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -557,9 +557,7 @@ def test_migration_documentation_covers_every_supported_bridge( ) -> None: """Public compatibility stays explicit and canonical-output-only.""" project_root = Path(__file__).resolve().parent.parent - migration = ( - project_root / "MIGRATION.md" - ).read_text(encoding="utf-8") + migration = (project_root / "docs" / "MIGRATION.md").read_text(encoding="utf-8") command_flags = { "default_mode": "default", diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index 1fd3703a..a1c2be6d 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -369,7 +369,7 @@ def test_preflight_rejects_collector_imports_of_application_and_orchestration_se "from logicytics import run_collection", "from logicytics import packaging", "import logicytics\nlogicytics.run_collection", - "import logicytics\nlogicytics.module.configuration", + "import logicytics.module.configuration", "from logicytics.module.api import query_run", ) for statement in attempts: From 8523c06c95a2ea923d29019b38115357c9696ba5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 19:15:36 +0300 Subject: [PATCH 244/316] feat(cli): unify console result presentation Keep non-event command output out of structured file logs, render run and plan summaries in console boxes, and route dynamically discovered test-suite results through the same presentation system. Add coverage for test-runner outcomes and the approved global ctypes export. --- docs/CONFIGURATION.md | 2 + logicytics/cli/commands.py | 29 ++++++++------ logicytics/cli/tests.py | 43 ++++++++++++++++---- logicytics/module/discovery.py | 6 ++- logicytics/module/logging.py | 41 +++++++++++++------ tests/test_cli.py | 13 +++--- tests/test_logging.py | 5 +++ tests/test_planning.py | 2 +- tests/test_preflight.py | 19 +++++++++ tests/test_public_api.py | 20 +++++----- tests/test_test_runner_cli.py | 72 ++++++++++++++++++++++++++++++++++ 11 files changed, 203 insertions(+), 49 deletions(-) create mode 100644 tests/test_test_runner_cli.py diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e161b688..e5dd4e85 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,6 +17,8 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. +Application events use the same structured data for both sinks. File rows follow `TIME | SEVERITY | CODE SOURCE | MESSAGE`; console-only command results are rendered as grey ASCII boxes and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. + ## Collector settings The `collectors` mapping is keyed by a validated collector ID. Shipped collectors reject settings they do not declare. Extension IDs may define their own settings. The shipped configurable fields are: diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index cd02ec5a..5d95dbbc 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -942,12 +942,13 @@ def main(argv: list[str] | None = None) -> int: ) if arguments.command == "plan": - print( - "\n".join( + application_logger.box( + "Collection plan", + tuple( candidate.metadata.id for candidate in plan.collectors if candidate.metadata is not None - ) + ) or ("No collectors were selected.",), ) return 0 @@ -957,7 +958,7 @@ def main(argv: list[str] | None = None) -> int: configuration, ).run(plan) - print("Collectors:") + result_lines = ["Collectors:"] for record in outcome.manifest.collectors: duration = ( @@ -966,7 +967,7 @@ def main(argv: list[str] | None = None) -> int: else f"{record.duration_seconds:.3f}" ) - print( + result_lines.append( f"- {record.id} " f"status={record.status} " f"duration_seconds={duration} " @@ -974,7 +975,7 @@ def main(argv: list[str] | None = None) -> int: ) if record.failure is not None: - print( + result_lines.append( f" failure " f"operation={record.failure['operation']} " f"retry_safe=" @@ -992,7 +993,7 @@ def main(argv: list[str] | None = None) -> int: / "performance.json" ) - print( + result_lines.append( f"Performance: {performance_path}" ) @@ -1000,7 +1001,7 @@ def main(argv: list[str] | None = None) -> int: outcome.manifest.package and "path" in outcome.manifest.package ): - print( + result_lines.extend(( f"Package: " f"{outcome.manifest.package['path']}\n" f"SHA-256: " @@ -1008,12 +1009,13 @@ def main(argv: list[str] | None = None) -> int: 'sha256_path', 'unavailable', )}" - ) + ).splitlines()) - print( + result_lines.extend(( f"Run: {outcome.manifest_path}\n" f"Status: {outcome.manifest.status.value}" - ) + ).splitlines()) + application_logger.box("Collection result", result_lines) exit_code = ( 0 @@ -1043,7 +1045,10 @@ def main(argv: list[str] | None = None) -> int: error_type=type(error).__name__, ) - print(f"Error: {error}") + if application_logger is not None: + application_logger.box("Command error", (str(error),)) + else: + print(f"Error: {error}") return 2 diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index c15a3f2f..7c8e24e2 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -3,10 +3,21 @@ from __future__ import annotations import argparse +import io import sys import unittest from pathlib import Path +from logicytics.module.configuration import load_config +from logicytics.module.errors import PlanError +from logicytics.module.logging import get_application_logger +from logicytics.module.output_layout import ensure_output_layout + + +def project_root() -> Path: + """Return the repository root containing the dynamically discovered test package.""" + return Path(__file__).resolve().parents[2] + def _require_virtual_environment() -> None: """Reject normal tooling execution outside the environment prepared by installer.""" @@ -24,14 +35,32 @@ def main(argv: list[str] | None = None) -> int: except RuntimeError as error: print(f"ERROR | {error}", file=sys.stderr) return 2 - root = Path(__file__).resolve().parents[2] - result = unittest.TextTestRunner(verbosity=arguments.verbosity).run( - unittest.defaultTestLoader.discover(str(root / "tests"), top_level_dir=str(root)) - ) - print( - f"Result: failures={len(result.failures)} errors={len(result.errors)} skipped={len(result.skipped)} " - f"tests={result.testsRun}" + root = project_root() + try: + configuration = load_config(root) + layout = ensure_output_layout(configuration.runtime.output_root) + except (OSError, PlanError) as error: + print(f"ERROR | unable to prepare test presentation: {error}", file=sys.stderr) + return 2 + logger = get_application_logger(layout.application_log, configuration.logging) + logger.event("INFO", "test_suite_started", source="logicytics.cli.tests") + transcript = io.StringIO() + suite = unittest.defaultTestLoader.discover(str(root / "tests"), top_level_dir=str(root)) + result = unittest.TextTestRunner(stream=transcript, verbosity=arguments.verbosity).run(suite) + summary = ( + f"Tests: {result.testsRun}", + f"Failures: {len(result.failures)}", + f"Errors: {len(result.errors)}", + f"Skipped: {len(result.skipped)}", + f"Result: {'passed' if result.wasSuccessful() else 'failed'}", ) + logger.box("Test suite", summary) + if not result.wasSuccessful(): + details = tuple(line for line in transcript.getvalue().splitlines() if line.strip()) + logger.box("Test failures", details or ("The test runner did not provide failure details.",)) + logger.event("ERROR", "test_suite_failed", source="logicytics.cli.tests") + else: + logger.event("INFO", "test_suite_finished", source="logicytics.cli.tests") return 0 if result.wasSuccessful() else 1 diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index 6b211af5..278af5d8 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -26,7 +26,11 @@ "run_collection", "validation_worker", } _CACHE_SCHEMA_VERSION = 2 -_COLLECTOR_SERVICE_MODULES = {"logicytics.contracts", "logicytics.platform_adapters"} +_COLLECTOR_SERVICE_MODULES = { + "logicytics.contracts", + "logicytics.global.ctypes_collector", + "logicytics.platform_adapters", +} @dataclass(frozen=True, slots=True) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 0c9ea452..8025091c 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -36,6 +36,7 @@ "EXCEPTION": "\033[91m", "CRITICAL": "\033[97;41m", } +_BOX_COLOR = "\033[90m" _LOGGER_LOCK = RLock() _APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} _EVENT_LOGGERS: dict[tuple[Path, str, str | None], "FileEventLogger"] = {} @@ -81,7 +82,10 @@ def _truncate_file(self) -> None: retained = stream.read() newline = retained.find(b"\n") retained = retained[newline + 1:] if newline >= 0 else retained - self.path.write_bytes(b"[log truncated to configured maximum]\n" + retained) + marker = "\n".join( + self._rows("WARNING", "logicytics.logging", "log truncated to configured maximum") + ).encode("utf-8") + b"\n" + self.path.write_bytes(marker + retained) @staticmethod def _rows(level: str, source: str, message: str) -> tuple[str, ...]: @@ -123,33 +127,44 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: self.console.flush() def raw(self, message: str, *, end: str = "\n") -> None: - """Write redacted raw text while retaining configured sink ownership.""" + """Write redacted console-only presentation that never pollutes the event log.""" if end not in {"", "\n"}: raise ValueError("raw log end must be empty or a newline") safe = redact_text(message) with self._lock: - if self.settings.file_enabled: - with self.path.open("a", encoding="utf-8") as stream: - stream.write(safe + end) - self._truncate_file() if self.settings.console_enabled: self.console.write(safe + end) self.console.flush() def separator(self) -> None: - """Write one blank line through the raw logging path.""" + """Write one presentation-only blank line to the configured console.""" self.raw("") def box(self, title: str, lines: Iterable[str]) -> None: - """Render non-log command output in a restrained grey ASCII panel.""" - content = tuple(redact_text(line) for line in lines) - width = min(100, max([len(title) + 4, *(len(line) + 2 for line in content)])) + """Render non-log command output in a wrapped, restrained grey ASCII panel.""" + content: list[str] = [] + for line in lines: + safe = redact_text(line) + wrapped = textwrap.wrap( + safe, + width=96, + break_long_words=False, + break_on_hyphens=False, + ) or [""] + for row in wrapped: + content.extend(row[index:index + 96] for index in range(0, len(row), 96)) + width = max(len(title) + 4, *(len(line) + 2 for line in content), 4) border = "+" + "-" * width + "+" rows = [border, f"| {title[:width - 2]:<{width - 2}} |", border] - rows.extend(f"| {line[:width - 2]:<{width - 2}} |" for line in content) + rows.extend(f"| {line:<{width - 2}} |" for line in content) rows.append(border) - for row in rows: - self.raw(row) + with self._lock: + if self.settings.console_enabled: + if self.settings.color_enabled and self.console.isatty(): + self.console.write(f"{_BOX_COLOR}{chr(10).join(rows)}\033[0m\n") + else: + self.console.write("\n".join(rows) + "\n") + self.console.flush() def dispatch(self, messages: Iterable[str]) -> None: """Parse and dispatch a batch of optional `LEVEL: message` rows.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index ca957703..ed587989 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -193,22 +193,23 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( self.assertEqual(321, payload["launched_process_id"]) launch.assert_called_once_with(root, "debug") + error_logger = MagicMock() with patch.object( CLI, CLI.project_root.__name__, return_value=root, ), patch( - "sys.stdout", - new_callable=io.StringIO, - ) as invalid_output: + "logicytics.cli.commands.get_application_logger", + return_value=error_logger, + ): self.assertEqual( 2, main(["update", "--new-window"]), ) - self.assertIn( - "must be provided together", - invalid_output.getvalue(), + error_logger.box.assert_called_once_with( + "Command error", + ("--new-window and --launch-action must be provided together",), ) def test_new_window_launcher_uses_current_interpreter_without_a_shell( diff --git a/tests/test_logging.py b/tests/test_logging.py index 612053d5..0977bd75 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -63,6 +63,7 @@ def isatty(self) -> bool: logger.dispatch(("WARNING: parsed warning", "plain batch row")) logger.raw("raw access_token=hidden") logger.separator() + logger.box("Presentation", ("non-log output stays out of the event file",)) for index in range(40): logger.event("INFO", "bounded row " + str(index) + " " + "x" * 80) @@ -72,6 +73,10 @@ def isatty(self) -> bool: self.assertLessEqual(path.stat().st_size, settings.maximum_bytes) self.assertFalse(old.exists()) self.assertIn("\033[", console.getvalue()) + self.assertIn("Presentation", console.getvalue()) + self.assertNotIn("Presentation", contents) + self.assertNotIn("raw", contents) + self.assertTrue(all(" | " in line for line in contents.splitlines())) self.assertIn("| EXCEPTION", console.getvalue()) with self.assertRaisesRegex(ValueError, "unsupported log level"): logger.event("TRACE", "unsupported") diff --git a/tests/test_planning.py b/tests/test_planning.py index 6eef9504..9649518f 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -339,7 +339,7 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( "project_root", return_value=root, ), patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual( diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 27d290a6..092e1752 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -31,6 +31,25 @@ class PreflightTests(unittest.TestCase): """Static/runtime preflight validation and quarantine behavior.""" + def test_preflight_allows_global_ctypes_collector_infrastructure(self) -> None: + """Collectors may use the public ctypes export without accessing the application layer.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\n" + "from logicytics import ctypes_collector\n", + ), + encoding="utf-8", + ) + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) + self.assertEqual((), report.invalid) + def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(self) -> None: """A plugin cannot silently inherit fields that affect consent or scheduling.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2906d3d5..0b7cc14d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,6 +1,5 @@ from __future__ import annotations -import io import json import os import subprocess @@ -10,7 +9,7 @@ from dataclasses import FrozenInstanceError from pathlib import Path from typing import Iterable -from unittest.mock import patch +from unittest.mock import MagicMock, patch from logicytics import ( load_configuration, @@ -270,15 +269,15 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat encoding="utf-8", ) - output = io.StringIO() + console_logger = MagicMock() with patch.object( CLI, "project_root", return_value=root, ), patch( - "sys.stdout", - output, + "logicytics.cli.commands.get_application_logger", + return_value=console_logger, ), patch( "builtins.input", return_value="", @@ -292,16 +291,19 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat ] ) - self.assertEqual(0, exit_code, output.getvalue()) + self.assertEqual(0, exit_code) final_prompt.assert_called_once_with("Press Enter to exit...") - self.assertIn("Collectors:", output.getvalue()) + console_logger.box.assert_called_once() + title, rendered = console_logger.box.call_args.args + self.assertEqual("Collection result", title) + self.assertIn("Collectors:", rendered) self.assertIn( "core.system.system_info status=succeeded duration_seconds=", - output.getvalue(), + "\n".join(rendered), ) self.assertIn( "summary=test artifact created", - output.getvalue(), + "\n".join(rendered), ) def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links(self) -> None: diff --git a/tests/test_test_runner_cli.py b/tests/test_test_runner_cli.py new file mode 100644 index 00000000..bf4cfe97 --- /dev/null +++ b/tests/test_test_runner_cli.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock, patch + +from logicytics.cli import tests as test_runner + + +class TestRunnerCliTests(unittest.TestCase): + """Presentation and result handling for the dynamically discovered test command.""" + + def test_runner_reports_discovered_suite_through_the_application_console(self) -> None: + """A successful dynamic run renders a boxed summary and returns a CI-success status.""" + result = MagicMock() + result.failures = () + result.errors = () + result.skipped = (object(),) + result.testsRun = 7 + result.wasSuccessful.return_value = True + logger = MagicMock() + runner = MagicMock() + runner.run.return_value = result + + with patch.object(test_runner, "_require_virtual_environment"), \ + patch.object(test_runner, "load_config", return_value=MagicMock()), \ + patch.object(test_runner, "ensure_output_layout", return_value=MagicMock()), \ + patch.object(test_runner, "get_application_logger", return_value=logger), \ + patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite()), \ + patch.object(test_runner.unittest, "TextTestRunner", return_value=runner): + self.assertEqual(0, test_runner.main(["--verbosity", "0"])) + + logger.box.assert_called_once_with( + "Test suite", + ("Tests: 7", "Failures: 0", "Errors: 0", "Skipped: 1", "Result: passed"), + ) + logger.event.assert_any_call("INFO", "test_suite_started", source="logicytics.cli.tests") + logger.event.assert_any_call("INFO", "test_suite_finished", source="logicytics.cli.tests") + + def test_runner_boxes_failure_details_and_returns_nonzero(self) -> None: + """Failed discovery remains CI-visible while preserving the runner's diagnostic transcript.""" + result = MagicMock() + result.failures = ((object(), "assertion failed"),) + result.errors = () + result.skipped = () + result.testsRun = 1 + result.wasSuccessful.return_value = False + logger = MagicMock() + + class FailingRunner: + """Emit a representative unittest transcript before returning a failed result.""" + + def __init__(self, *, stream, **_: object) -> None: + self.stream = stream + + def run(self, _: unittest.TestSuite) -> MagicMock: + self.stream.write("FAIL: example test\nassertion failed\n") + return result + + with patch.object(test_runner, "_require_virtual_environment"), \ + patch.object(test_runner, "load_config", return_value=MagicMock()), \ + patch.object(test_runner, "ensure_output_layout", return_value=MagicMock()), \ + patch.object(test_runner, "get_application_logger", return_value=logger), \ + patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite()), \ + patch.object(test_runner.unittest, "TextTestRunner", FailingRunner): + self.assertEqual(1, test_runner.main([])) + + logger.box.assert_any_call("Test failures", ("FAIL: example test", "assertion failed")) + logger.event.assert_any_call("ERROR", "test_suite_failed", source="logicytics.cli.tests") + + +if __name__ == "__main__": + unittest.main() From 1fc1903557558618264c0eeeb69a917f3830ebe6 Mon Sep 17 00:00:00 2001 From: Shahm Najeeb Date: Mon, 7 Sep 2026 20:46:47 +0300 Subject: [PATCH 245/316] fix: added git required patterns for commits Signed-off-by: DefinetlyNotAI --- .github/script/validate-commit-msg.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/script/validate-commit-msg.sh b/.github/script/validate-commit-msg.sh index 6bcad578..7c333f4a 100644 --- a/.github/script/validate-commit-msg.sh +++ b/.github/script/validate-commit-msg.sh @@ -5,7 +5,7 @@ set -eu commit_msg_file="$1" first_line="$(head -n 1 "$commit_msg_file")" -pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([A-Za-z0-9._/-]+\))?!?: .+$' +pattern='^((fixup|squash)! )?(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([A-Za-z0-9._/-]+\))?!?: .+$' if printf '%s\n' "$first_line" | grep -Eq "$pattern"; then exit 0 From 0cda40b8e4af282be6ca947bbe391d00837d9582 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 20:48:43 +0300 Subject: [PATCH 246/316] fix: missing __all__ imports Signed-off-by: DefinetlyNotAI --- logicytics/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 667fd023..f0901133 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -23,6 +23,8 @@ Specialty, ValidationResult, ) +from logicytics.module.api import CollectorFailureSnapshot, CollectorSnapshot, RunSnapshot, load_configuration, \ + open_artifact, plan_run, query_run, read_artifact, run_collection _APPLICATION_EXPORTS = frozenset({ "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "open_artifact", "plan_run", From e0d5807065467b606a0edd69906703c60c6b1f8b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 20:52:58 +0300 Subject: [PATCH 247/316] chore: reformat Signed-off-by: DefinetlyNotAI --- core/memory/memory_snapshot.py | 2 +- core/process/memory_map.py | 2 +- core/storage/logical_drives.py | 2 +- core/storage/volume_details.py | 2 +- docs/CONFIGURATION.md | 26 ++++++++++++------------- docs/MIGRATION.md | 16 +++++++-------- logicytics/cli/__main__.py | 1 - logicytics/cli/commands.py | 27 +++++++++++++------------- logicytics/cli/installer.py | 1 - logicytics/module/api.py | 2 +- logicytics/module/logging.py | 4 +++- logicytics/module/manifest.py | 2 +- logicytics/module/packaging.py | 2 +- logicytics/module/runtime.py | 6 +++--- logicytics/module/validation_worker.py | 12 ++++++------ logicytics/platform_adapters.py | 6 +++--- tests/test_configuration.py | 7 ++++--- 17 files changed, 61 insertions(+), 59 deletions(-) diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index db50447f..ee0af50b 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -6,8 +6,8 @@ from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics.contracts import CollectorContext, CollectorStatus from logicytics import global_memory_status +from logicytics.contracts import CollectorContext, CollectorStatus class MemorySnapshotCollector(CoreCollector): diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 358b5581..47df20d8 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -6,9 +6,9 @@ from pathlib import Path from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics.contracts import CollectorContext, CollectorStatus from logicytics import ProcessMemoryCounters, MemoryBasicInformation, get_process_memory_info, \ virtual_query, get_mapped_file_name, get_current_process, pointer_value +from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index 4b915aa1..f8e4737d 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -6,8 +6,8 @@ from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics.contracts import CollectorContext, CollectorStatus from logicytics import get_logical_drives, ularge_integer, get_disk_free_space, get_drive_type +from logicytics.contracts import CollectorContext, CollectorStatus _DRIVE_TYPES = { 0: "unknown", diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index d192ac89..ce6ee6f0 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -6,9 +6,9 @@ from datetime import datetime, timezone from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics.contracts import CollectorContext, CollectorStatus from logicytics import get_volume_information, get_disk_free_space, ularge_integer, \ create_unicode_buffer, dword +from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import windows_api_adapter _DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e5dd4e85..faa062a3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -8,12 +8,12 @@ Unknown keys, duplicate keys, unsafe paths, non-finite numbers, unsupported valu The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, `logging`, and `collectors`. The current schema is version `4`. -| Section | Fields | -| --- | --- | -| `runtime` | `output_root`, `default_max_workers`, `maximum_workers`, `package_completed_runs`, `maximum_run_output_bytes` | -| `interaction` | `history_enabled`, `similarity_threshold`, `model_name`, `model_debug` | +| Section | Fields | +|---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `runtime` | `output_root`, `default_max_workers`, `maximum_workers`, `package_completed_runs`, `maximum_run_output_bytes` | +| `interaction` | `history_enabled`, `similarity_threshold`, `model_name`, `model_debug` | | `maintenance` | `remote_manifest_url`, `remote_manifest_sha256`, `local_manifest_path`, `minimum_python`, `recommended_python`, `sysinternals_enabled`, `sysinternals_download_url` | -| `logging` | `level`, `console_enabled`, `color_enabled`, `file_enabled`, `maximum_bytes`, `delete_previous`, `retention_days` | +| `logging` | `level`, `console_enabled`, `color_enabled`, `file_enabled`, `maximum_bytes`, `delete_previous`, `retention_days` | `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. @@ -23,14 +23,14 @@ Application events use the same structured data for both sinks. File rows follow The `collectors` mapping is keyed by a validated collector ID. Shipped collectors reject settings they do not declare. Extension IDs may define their own settings. The shipped configurable fields are: -| Collector | Fields and bounds | -| --- | --- | -| `core.network.bandwidth_sample` | `sample_count` 1–10; `interval_seconds` 0.1–60 | -| `core.packet.packet_capture` | `packet_count` 1–10000; `timeout_seconds` 1–60; `retry_window_seconds` 0–60; `interface` text | -| `core.filesystem.system_drive_tree` | `max_entries` 1–50000; `max_depth` 1–32 | -| `core.filesystem.system_drive_listing` | `max_entries` 1–50000; `max_depth` 1–32 | -| `core.filesystem.sensitive_file_inventory` | `root` absolute path; `max_directories` 1–50000; `max_matches` 1–5000 | -| `core.process.memory_map` | `max_regions` 1–100000; `output_limit_bytes` 1024–67108864; `disk_safety_margin_bytes` 0–68719476736; `dump_directory` relative workspace path | +| Collector | Fields and bounds | +|--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------| +| `core.network.bandwidth_sample` | `sample_count` 1–10; `interval_seconds` 0.1–60 | +| `core.packet.packet_capture` | `packet_count` 1–10000; `timeout_seconds` 1–60; `retry_window_seconds` 0–60; `interface` text | +| `core.filesystem.system_drive_tree` | `max_entries` 1–50000; `max_depth` 1–32 | +| `core.filesystem.system_drive_listing` | `max_entries` 1–50000; `max_depth` 1–32 | +| `core.filesystem.sensitive_file_inventory` | `root` absolute path; `max_directories` 1–50000; `max_matches` 1–5000 | +| `core.process.memory_map` | `max_regions` 1–100000; `output_limit_bytes` 1024–67108864; `disk_safety_margin_bytes` 0–68719476736; `dump_directory` relative workspace path | ## Complete example diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 1d105ce2..d31bba64 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -8,14 +8,14 @@ Run `python -m logicytics.cli.installer` to prepare the virtual environment and The old mode flags remain explicit compatibility aliases. Their v4 equivalents are: -| Legacy flag | v4 mode | -| --- | --- | -| `--default` | `--mode standard` | -| `--threaded` | `--mode balanced` | -| `--minimal` | `--mode quick` | -| `--depth` | `--mode thorough` | -| `--modded` | `--mode extensions` | -| `--nopy` | `--mode non-python` | +| Legacy flag | v4 mode | +|-----------------------|----------------------| +| `--default` | `--mode standard` | +| `--threaded` | `--mode balanced` | +| `--minimal` | `--mode quick` | +| `--depth` | `--mode thorough` | +| `--modded` | `--mode extensions` | +| `--nopy` | `--mode non-python` | | `--performance-check` | `--mode performance` | Only one mode or alias may be selected per invocation. Prefer named `--mode` values in new automation. diff --git a/logicytics/cli/__main__.py b/logicytics/cli/__main__.py index a9d83714..678cf214 100644 --- a/logicytics/cli/__main__.py +++ b/logicytics/cli/__main__.py @@ -2,6 +2,5 @@ from logicytics.cli.commands import main - if __name__ == "__main__": raise SystemExit(main()) diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 5d95dbbc..e266c754 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -12,8 +12,8 @@ from pathlib import Path from typing import Literal -from logicytics.module.configuration import AppConfig, load_config from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest +from logicytics.module.configuration import AppConfig, load_config from logicytics.module.discovery import preflight from logicytics.module.environment import inspect_environment from logicytics.module.errors import LogicyticsError @@ -38,13 +38,14 @@ ) from logicytics.module.output_layout import ensure_output_layout from logicytics.module.planner import BUILTIN_PROFILES, build_plan -from logicytics.platform_adapters import process_adapter from logicytics.module.runtime import RunSupervisor from logicytics.module.sysinternals import ensure_sysinternals +from logicytics.platform_adapters import process_adapter class CLI: """Translate command-line arguments into validated application requests.""" + @staticmethod def render_preflight( logger: object, @@ -1002,19 +1003,19 @@ def main(argv: list[str] | None = None) -> int: and "path" in outcome.manifest.package ): result_lines.extend(( - f"Package: " - f"{outcome.manifest.package['path']}\n" - f"SHA-256: " - f"{outcome.manifest.package.get( - 'sha256_path', - 'unavailable', - )}" - ).splitlines()) + f"Package: " + f"{outcome.manifest.package['path']}\n" + f"SHA-256: " + f"{outcome.manifest.package.get( + 'sha256_path', + 'unavailable', + )}" + ).splitlines()) result_lines.extend(( - f"Run: {outcome.manifest_path}\n" - f"Status: {outcome.manifest.status.value}" - ).splitlines()) + f"Run: {outcome.manifest_path}\n" + f"Status: {outcome.manifest.status.value}" + ).splitlines()) application_logger.box("Collection result", result_lines) exit_code = ( diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index a2102319..04fbce77 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -3,7 +3,6 @@ from __future__ import annotations import argparse -import sys import venv from pathlib import Path diff --git a/logicytics/module/api.py b/logicytics/module/api.py index 9d9b493b..f27b0277 100644 --- a/logicytics/module/api.py +++ b/logicytics/module/api.py @@ -13,8 +13,8 @@ from pathlib import Path, PurePosixPath from typing import Any -from logicytics.module.configuration import AppConfig, load_config from logicytics.contracts import Artifact, CollectorStatus, RunRequest, RunStatus +from logicytics.module.configuration import AppConfig, load_config from logicytics.module.discovery import preflight from logicytics.module.errors import ArtifactError, PlanError from logicytics.module.manifest import MANIFEST_SCHEMA_VERSION diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 8025091c..49ce18e6 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -12,8 +12,8 @@ from time import perf_counter, time from typing import Callable, Iterable, ParamSpec, TextIO, TypeVar -from logicytics.module.configuration import LoggingSettings from logicytics.contracts import EventLogger +from logicytics.module.configuration import LoggingSettings from logicytics.module.redaction import redact_mapping, redact_text Parameters = ParamSpec("Parameters") @@ -241,6 +241,7 @@ def timed( def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: """Wrap one callable with lifecycle timing events.""" + def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: """Emit start, completion, or failure events around one invocation.""" logger.event(level, "function_started", function=function.__qualname__) @@ -277,6 +278,7 @@ def deprecated( def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: """Wrap one callable with a structured deprecation warning.""" + def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: """Emit the configured warning and then invoke the deprecated callable.""" fields: dict[str, str] = {"function": function.__qualname__, "removal_version": removal_version, diff --git a/logicytics/module/manifest.py b/logicytics/module/manifest.py index 5703c95a..de79c7b1 100644 --- a/logicytics/module/manifest.py +++ b/logicytics/module/manifest.py @@ -12,8 +12,8 @@ from typing import Any, Mapping from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus -from logicytics.platform_adapters import windows_api_adapter from logicytics.module.redaction import redact_mapping, redact_text +from logicytics.platform_adapters import windows_api_adapter MANIFEST_SCHEMA_VERSION = 1 diff --git a/logicytics/module/packaging.py b/logicytics/module/packaging.py index aec226df..70af933a 100644 --- a/logicytics/module/packaging.py +++ b/logicytics/module/packaging.py @@ -11,8 +11,8 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, IO -from logicytics.module.artifacts import sha256_file from logicytics.contracts import Artifact +from logicytics.module.artifacts import sha256_file from logicytics.module.manifest import RunManifest, write_manifest if TYPE_CHECKING: diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index da01d32c..f68d9842 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -25,9 +25,6 @@ from typing import Any, Mapping, TypedDict, cast, Callable, Protocol, TypeVar from uuid import uuid4 -from logicytics.module.artifacts import WorkspaceArtifactWriter -from logicytics.module.command_runner import parse_level_messages -from logicytics.module.configuration import AppConfig from logicytics.contracts import ( Artifact, Capability, @@ -41,6 +38,9 @@ RunStatus, ValidationResult, ) +from logicytics.module.artifacts import WorkspaceArtifactWriter +from logicytics.module.command_runner import parse_level_messages +from logicytics.module.configuration import AppConfig from logicytics.module.discovery import CollectorCandidate from logicytics.module.errors import LogicyticsError from logicytics.module.logging import FileEventLogger, get_application_logger, get_event_logger diff --git a/logicytics/module/validation_worker.py b/logicytics/module/validation_worker.py index 4a513f22..983d65e6 100644 --- a/logicytics/module/validation_worker.py +++ b/logicytics/module/validation_worker.py @@ -171,11 +171,11 @@ def _validate_contract( declared_dependencies = dependencies() if ( - not isinstance(declared_dependencies, tuple) - or not all( - isinstance(item, str) - for item in declared_dependencies - ) + not isinstance(declared_dependencies, tuple) + or not all( + isinstance(item, str) + for item in declared_dependencies + ) ): raise ValueError( "dependencies() must return tuple[str, ...]" @@ -196,7 +196,7 @@ def _validate_contract( collector = collector_type() with tempfile.TemporaryDirectory( - prefix="logicytics-validation-" + prefix="logicytics-validation-" ) as temporary: workspace = Path(temporary) temporary_directory = workspace / "tmp" diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index a4936696..cfce57d0 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -298,9 +298,9 @@ class WindowsApiAdapter: def load_library(name: str): """Load one validated Win32 DLL name when running on Windows.""" if ( - not isinstance(name, str) - or not name - or any(character in name for character in "/\\\x00") + not isinstance(name, str) + or not name + or any(character in name for character in "/\\\x00") ): raise ValueError( "Win32 library name must be a simple non-empty name" diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 2bd38084..4286e756 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -7,13 +7,13 @@ from pathlib import Path from typing import Any, cast +from logicytics.contracts import ( + RunRequest, +) from logicytics.module.configuration import ( default_config, load_config, ) -from logicytics.contracts import ( - RunRequest, -) from logicytics.module.discovery import preflight from logicytics.module.errors import PlanError from logicytics.module.planner import build_plan @@ -90,6 +90,7 @@ def test_historical_code_config_ini_is_not_loaded(self) -> None: (root / "logicytics.yaml").write_text('{"schema_version":4}', encoding="utf-8") self.assertIsNone(load_config(root).migrated_from_schema) self.assertFalse((root / "output").exists()) + def test_non_yaml_configuration_is_rejected(self) -> None: """Superseded JSON settings files are rejected instead of becoming a second source of truth.""" with tempfile.TemporaryDirectory() as temporary: From de7564925b13c36a0b17eef6bcfd18c3cfd8c84b Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Mon, 7 Sep 2026 21:06:13 +0300 Subject: [PATCH 248/316] feat(logging): align output with AIBrain Adopt compact severity markers, terminal-aware wrapping, rounded summary panels, and Unicode-safe fallbacks for console output. Format application logs with aligned local timestamps and 140-column rows while retaining only complete bounded records. Signed-off-by: DefinetlyNotAI --- README.md | 8 +- docs/CONFIGURATION.md | 2 +- logicytics.yaml | 2 +- logicytics/module/logging.py | 186 +++++++++++++++++++++++++++-------- tests/test_logging.py | 45 ++++++++- 5 files changed, 193 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index aacb4827..6fd537f0 100644 --- a/README.md +++ b/README.md @@ -29,20 +29,20 @@ The installer is the only Logicytics command designed to run outside a virtual e ```powershell python -m logicytics.cli.installer -.\.venv\Scripts\python.exe -m logicytics preflight +python -m logicytics preflight ``` When preflight is clean, plan before collecting: ```powershell -.\.venv\Scripts\python.exe -m logicytics plan --profile standard -.\.venv\Scripts\python.exe -m logicytics run --profile standard --acknowledge-authorization +python -m logicytics plan --profile standard +python -m logicytics run --profile standard --acknowledge-authorization ``` Use the bundled runner for the complete dynamically discovered test suite: ```powershell -.\.venv\Scripts\python.exe -m logicytics.cli.tests +python -m logicytics.cli.tests ``` ## What to expect diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index faa062a3..63ca59cf 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,7 +17,7 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. -Application events use the same structured data for both sinks. File rows follow `TIME | SEVERITY | CODE SOURCE | MESSAGE`; console-only command results are rendered as grey ASCII boxes and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. +Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping. File rows use the aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Console-only command results use the same rounded summary panels as AIBrain and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. ## Collector settings diff --git a/logicytics.yaml b/logicytics.yaml index 052e8cc0..728973c0 100644 --- a/logicytics.yaml +++ b/logicytics.yaml @@ -22,6 +22,6 @@ logging: color_enabled: true file_enabled: true maximum_bytes: 4194304 - delete_previous: false + delete_previous: true retention_days: 30 collectors: {} diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 49ce18e6..3dfc39fb 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -3,6 +3,8 @@ from __future__ import annotations import json +import re +import shutil import sys import textwrap import traceback @@ -27,16 +29,26 @@ "EXCEPTION": 45, "CRITICAL": 50, } -_LEVEL_COLORS = { - "DEBUG": "\033[36m", - "INTERNAL": "\033[35m", - "INFO": "\033[32m", - "WARNING": "\033[33m", - "ERROR": "\033[31m", - "EXCEPTION": "\033[91m", - "CRITICAL": "\033[97;41m", +_LEVEL_PRESENTATION = { + "DEBUG": ("\u00b7", "\033[90m"), + "INTERNAL": ("\u00b7", "\033[95m"), + "INFO": ("\u25cf", "\033[96m"), + "WARNING": ("!", "\033[93m"), + "ERROR": ("\u00d7", "\033[91m"), + "EXCEPTION": ("\u00d7", "\033[91m"), + "CRITICAL": ("\u00d7", "\033[91m"), } -_BOX_COLOR = "\033[90m" +_RESET = "\033[0m" +_BOLD = "\033[1m" +_BOX_COLOR = "\033[96m" +_FILE_LOG_LINE_WIDTH = 140 +_TIME_WIDTH = 19 +_SEVERITY_WIDTH = 9 +_SOURCE_WIDTH = 28 +_DEFAULT_CONSOLE_WIDTH = 82 +_MIN_CONSOLE_WIDTH = 60 +_RIGHT_EDGE_MARGIN = 4 +_RECORD_START = re.compile(rb"(?m)^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") _LOGGER_LOCK = RLock() _APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} _EVENT_LOGGERS: dict[tuple[Path, str, str | None], "FileEventLogger"] = {} @@ -78,25 +90,81 @@ def _truncate_file(self) -> None: """Retain the newest complete rows when the configured byte limit is exceeded.""" if self.path.is_file() and self.path.stat().st_size > self.settings.maximum_bytes: with self.path.open("rb") as stream: - stream.seek(-self.settings.maximum_bytes // 2, 2) + stream.seek(-self.settings.maximum_bytes, 2) + stream.readline() retained = stream.read() - newline = retained.find(b"\n") - retained = retained[newline + 1:] if newline >= 0 else retained - marker = "\n".join( - self._rows("WARNING", "logicytics.logging", "log truncated to configured maximum") - ).encode("utf-8") + b"\n" - self.path.write_bytes(marker + retained) + record = _RECORD_START.search(retained) + retained = retained[record.start():] if record is not None else b"" + self.path.write_bytes(retained) @staticmethod def _rows(level: str, source: str, message: str) -> tuple[str, ...]: - """Format aligned, deterministic human log rows for file inspection.""" - timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S") - source_column = source[:24] - wrapped = textwrap.wrap(message, width=76, break_long_words=False, break_on_hyphens=False) or [""] - first = f"{timestamp} | {level:<9} | {source_column:<24} | {wrapped[0]}" - continuation = f"{'':8} | {'':9} | {'':24} | " + """Format AIBrain-style fixed columns and aligned wrapped rows.""" + timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") + source_column = source.removeprefix("logicytics.") + if len(source_column) > _SOURCE_WIDTH: + source_column = source_column[:_SOURCE_WIDTH - 3] + "..." + prefix = ( + f"{timestamp:<{_TIME_WIDTH}} | {level:<{_SEVERITY_WIDTH}} | " + f"{source_column:<{_SOURCE_WIDTH}} | " + ) + continuation = ( + f"{'':<{_TIME_WIDTH}} | {'':<{_SEVERITY_WIDTH}} | " + f"{'':<{_SOURCE_WIDTH}} | " + ) + available = max(_FILE_LOG_LINE_WIDTH - len(prefix), 1) + wrapped = [ + segment + for line in message.splitlines() or [""] + for segment in ( + textwrap.wrap( + line, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + ) + ] + first = prefix + wrapped[0] return tuple([first, *(continuation + row for row in wrapped[1:])]) + @staticmethod + def _console_width() -> int: + """Return the AIBrain console width with its safety margin and minimum.""" + width = shutil.get_terminal_size((_DEFAULT_CONSOLE_WIDTH, 24)).columns + return max(width - _RIGHT_EDGE_MARGIN, _MIN_CONSOLE_WIDTH) + + def _supports_unicode(self) -> bool: + """Return whether this console can encode AIBrain's presentation glyphs.""" + encoding = getattr(self.console, "encoding", None) or "utf-8" + try: + "\u00b7\u25cf\u00d7\u256d\u2500\u256e\u2502\u251c\u2524\u2570\u256f".encode(encoding) + except (LookupError, UnicodeEncodeError): + return False + return True + + @classmethod + def _console_rows(cls, marker: str, message: str) -> tuple[str, ...]: + """Word-wrap compact status text with aligned continuation indentation.""" + prefix = f" {marker} " + continuation = " " * len(prefix) + width = cls._console_width() + rows: list[str] = [] + for index, raw_line in enumerate(message.expandtabs(4).splitlines() or [""]): + indentation = raw_line[:len(raw_line) - len(raw_line.lstrip())] + remaining = raw_line.lstrip().rstrip() + current_prefix = (prefix if index == 0 else continuation) + indentation + while len(remaining) > max(width - len(current_prefix), 1): + available = max(width - len(current_prefix), 1) + split_at = remaining.rfind(" ", 0, available + 1) + if split_at <= 0: + split_at = available + rows.append(current_prefix + remaining[:split_at].rstrip()) + remaining = remaining[split_at:].lstrip() + current_prefix = continuation + indentation + rows.append(current_prefix + remaining) + return tuple(rows) + def event(self, level: str, message: str, **fields: int | float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" normalized = level.upper() @@ -111,19 +179,22 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: f"{key}={json.dumps(value, ensure_ascii=True, sort_keys=True)}" for key, value in sorted(safe_fields.items()) ) - rows = self._rows(normalized, source, safe_message + suffix) + rendered_message = safe_message + suffix + rows = self._rows(normalized, source, rendered_message) with self._lock: if self.settings.file_enabled: with self.path.open("a", encoding="utf-8") as stream: stream.write("\n".join(rows) + "\n") self._truncate_file() if self.settings.console_enabled: + marker, color = _LEVEL_PRESENTATION[normalized] + if not self._supports_unicode(): + marker = {"\u25cf": "*", "\u00d7": "X", "\u00b7": "."}.get(marker, marker) + console_rows = self._console_rows(marker, rendered_message) if self.settings.color_enabled and self.console.isatty(): - self.console.write(f"{_LEVEL_COLORS[normalized]}{rows[0]}\033[0m\n") - for row in rows[1:]: - self.console.write(row + "\n") + self.console.write(f"{color}{_BOLD}{chr(10).join(console_rows)}{_RESET}\n") else: - self.console.write("\n".join(rows) + "\n") + self.console.write("\n".join(console_rows) + "\n") self.console.flush() def raw(self, message: str, *, end: str = "\n") -> None: @@ -141,27 +212,50 @@ def separator(self) -> None: self.raw("") def box(self, title: str, lines: Iterable[str]) -> None: - """Render non-log command output in a wrapped, restrained grey ASCII panel.""" + """Render console-only output in an AIBrain-style summary panel.""" + width = self._console_width() + content_width = width - 4 content: list[str] = [] for line in lines: safe = redact_text(line) wrapped = textwrap.wrap( safe, - width=96, + width=content_width, break_long_words=False, break_on_hyphens=False, ) or [""] for row in wrapped: - content.extend(row[index:index + 96] for index in range(0, len(row), 96)) - width = max(len(title) + 4, *(len(line) + 2 for line in content), 4) - border = "+" + "-" * width + "+" - rows = [border, f"| {title[:width - 2]:<{width - 2}} |", border] - rows.extend(f"| {line:<{width - 2}} |" for line in content) - rows.append(border) + content.extend( + row[index:index + content_width] + for index in range(0, len(row), content_width) + ) + inner = width - 2 + if self._supports_unicode(): + horizontal, vertical = "\u2500", "\u2502" + top_left, top_right = "\u256d", "\u256e" + middle_left, middle_right = "\u251c", "\u2524" + bottom_left, bottom_right = "\u2570", "\u256f" + else: + horizontal, vertical = "-", "|" + top_left = top_right = "+" + middle_left = middle_right = "+" + bottom_left = bottom_right = "+" + top = top_left + horizontal * inner + top_right + middle = middle_left + horizontal * inner + middle_right + bottom = bottom_left + horizontal * inner + bottom_right + rows = [ + "", + top, + f"{vertical} {title[:content_width].center(content_width)} {vertical}", + middle, + *(f"{vertical} {line:<{content_width}} {vertical}" for line in content), + bottom, + "", + ] with self._lock: if self.settings.console_enabled: if self.settings.color_enabled and self.console.isatty(): - self.console.write(f"{_BOX_COLOR}{chr(10).join(rows)}\033[0m\n") + self.console.write(f"{_BOX_COLOR}{chr(10).join(rows)}{_RESET}\n") else: self.console.write("\n".join(rows) + "\n") self.console.flush() @@ -222,7 +316,9 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: stream.write(json.dumps(payload, sort_keys=True) + "\n") -def get_event_logger(path: Path, *, run_id: str, collector_id: str | None = None) -> FileEventLogger: +def get_event_logger( + path: Path, *, run_id: str, collector_id: str | None = None +) -> FileEventLogger: """Return the process-local singleton for one canonical engine or collector channel.""" identity = (path.resolve(), run_id, collector_id) with _LOGGER_LOCK: @@ -249,8 +345,13 @@ def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: try: value = function(*args, **kwargs) except Exception as error: - logger.event("error", "function_failed", function=function.__qualname__, - duration_seconds=round(perf_counter() - started, 6), error_type=type(error).__name__) + logger.event( + "error", + "function_failed", + function=function.__qualname__, + duration_seconds=round(perf_counter() - started, 6), + error_type=type(error).__name__, + ) raise logger.event(level, "function_finished", function=function.__qualname__, duration_seconds=round(perf_counter() - started, 6)) @@ -281,8 +382,11 @@ def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Res def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: """Emit the configured warning and then invoke the deprecated callable.""" - fields: dict[str, str] = {"function": function.__qualname__, "removal_version": removal_version, - "reason": reason} + fields: dict[str, str] = { + "function": function.__qualname__, + "removal_version": removal_version, + "reason": reason, + } if include_stack: fields["stack"] = "".join(traceback.format_stack(limit=8)) logger.event("warning", "function_deprecated", **fields) diff --git a/tests/test_logging.py b/tests/test_logging.py index 0977bd75..8bd76f42 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -77,7 +77,11 @@ def isatty(self) -> bool: self.assertNotIn("Presentation", contents) self.assertNotIn("raw", contents) self.assertTrue(all(" | " in line for line in contents.splitlines())) - self.assertIn("| EXCEPTION", console.getvalue()) + self.assertIn("\u00d7 typed event", console.getvalue()) + self.assertIn("\u256d", console.getvalue()) + first_row = contents.splitlines()[0] + self.assertRegex(first_row, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") + self.assertLessEqual(max(map(len, contents.splitlines())), 140) with self.assertRaisesRegex(ValueError, "unsupported log level"): logger.event("TRACE", "unsupported") with self.assertRaisesRegex(ValueError, "raw log end"): @@ -155,6 +159,32 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self with self.subTest(logging=invalid_logging), self.assertRaises(PlanError): load_config(root, config_path) + def test_application_logging_wraps_console_rows_and_falls_back_to_ascii(self) -> None: + """Compact console records stay aligned on terminals without Unicode support.""" + + class AsciiTerminalBuffer(io.StringIO): + encoding = "ascii" + + def isatty(self) -> bool: + return False + + with tempfile.TemporaryDirectory() as temporary: + console = AsciiTerminalBuffer() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(file_enabled=False, color_enabled=False), + console=console, + ) + logger.event("INFO", "a long message " * 12) + logger.box("Summary", ("finished",)) + + rows = console.getvalue().splitlines() + self.assertTrue(rows[0].startswith(" * ")) + self.assertTrue(rows[1].startswith(" ")) + self.assertTrue(any(row.startswith("+") for row in rows)) + self.assertNotIn("\u25cf", console.getvalue()) + self.assertNotIn("\u256d", console.getvalue()) + def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, Any]]] = [] @@ -206,7 +236,11 @@ def test_structured_event_logger_redacts_secret_fields_and_inline_credentials(se """Structured diagnostics must preserve useful fields without exposing secrets.""" with tempfile.TemporaryDirectory() as temporary: path = Path(temporary) / "events.jsonl" - logger = FileEventLogger(path, run_id="test-run", collector_id="core.system.private_keys") + logger = FileEventLogger( + path, + run_id="test-run", + collector_id="core.system.private_keys", + ) logger.event( "INFO", "Authorization: Bearer bearer-value password=message-value", @@ -231,7 +265,12 @@ def test_structured_event_logger_serializes_concurrent_jsonl_events(self) -> Non logger = FileEventLogger(path, run_id="concurrent-run", collector_id="core.system.test") def write_event(index: int) -> None: - logger.event("info", "collector_progress", sequence=index, password=f"secret-{index}") + logger.event( + "info", + "collector_progress", + sequence=index, + password=f"secret-{index}", + ) with ThreadPoolExecutor(max_workers=12) as executor: list(executor.map(write_event, range(120))) From 6cf2a59b5edded99baba9fc6d5c57dbe9bfaca71 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 00:09:35 +0300 Subject: [PATCH 249/316] fix(planning): report all collector policy failures Aggregate selected collector capability and platform policy errors so planning exposes every blocker in one run. Add preflight and plan lifecycle events, and render INFO text white with severity-colored markers while keeping DEBUG gray. --- logicytics/cli/commands.py | 44 ++++++++++++++++++++++++++++--- logicytics/module/logging.py | 27 ++++++++++++------- logicytics/module/planner.py | 19 +++++++++++--- tests/test_logging.py | 30 +++++++++++++++++++++- tests/test_planning.py | 50 ++++++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 18 deletions(-) diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index e266c754..bfcc42a1 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -766,10 +766,23 @@ def main(argv: list[str] | None = None) -> int: return 0 + application_logger.event( + "INFO", + "preflight_started", + source="logicytics.cli", + configuration_hash=configuration.fingerprint(), + ) report = preflight( root, configuration_hash=configuration.fingerprint(), ) + application_logger.event( + "INFO", + "preflight_finished", + source="logicytics.cli", + valid_collectors=len(report.valid), + invalid_collectors=len(report.invalid), + ) if arguments.command == "preflight": validation = report.to_dict( @@ -934,12 +947,29 @@ def main(argv: list[str] | None = None) -> int: arguments, ) + request = cli_methods.request( + arguments, + configuration.runtime.default_max_workers, + ) + application_logger.event( + "INFO", + "plan_requested", + source="logicytics.cli", + profile=request.profile, + include_count=len(request.include), + exclude_count=len(request.exclude), + approved_capabilities=len(request.approved_capabilities), + ) plan = build_plan( report, - cli_methods.request( - arguments, - configuration.runtime.default_max_workers, - ), + request, + ) + application_logger.event( + "INFO", + "plan_created", + source="logicytics.cli", + collectors=len(plan.collectors), + fingerprint=plan.fingerprint, ) if arguments.command == "plan": @@ -951,6 +981,12 @@ def main(argv: list[str] | None = None) -> int: if candidate.metadata is not None ) or ("No collectors were selected.",), ) + application_logger.event( + "INFO", + "plan_rendered", + source="logicytics.cli", + collectors=len(plan.collectors), + ) return 0 diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 3dfc39fb..b4fa5e16 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -30,13 +30,13 @@ "CRITICAL": 50, } _LEVEL_PRESENTATION = { - "DEBUG": ("\u00b7", "\033[90m"), - "INTERNAL": ("\u00b7", "\033[95m"), - "INFO": ("\u25cf", "\033[96m"), - "WARNING": ("!", "\033[93m"), - "ERROR": ("\u00d7", "\033[91m"), - "EXCEPTION": ("\u00d7", "\033[91m"), - "CRITICAL": ("\u00d7", "\033[91m"), + "DEBUG": ("\u00b7", "\033[90m", "\033[90m"), + "INTERNAL": ("\u00b7", "\033[95m", "\033[95m"), + "INFO": ("\u25cf", "\033[96m", "\033[97m"), + "WARNING": ("!", "\033[93m", "\033[93m"), + "ERROR": ("\u00d7", "\033[91m", "\033[91m"), + "EXCEPTION": ("\u00d7", "\033[91m", "\033[91m"), + "CRITICAL": ("\u00d7", "\033[91m", "\033[91m"), } _RESET = "\033[0m" _BOLD = "\033[1m" @@ -187,12 +187,21 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: stream.write("\n".join(rows) + "\n") self._truncate_file() if self.settings.console_enabled: - marker, color = _LEVEL_PRESENTATION[normalized] + marker, marker_color, text_color = _LEVEL_PRESENTATION[normalized] if not self._supports_unicode(): marker = {"\u25cf": "*", "\u00d7": "X", "\u00b7": "."}.get(marker, marker) console_rows = self._console_rows(marker, rendered_message) if self.settings.color_enabled and self.console.isatty(): - self.console.write(f"{color}{_BOLD}{chr(10).join(console_rows)}{_RESET}\n") + marker_prefix = f" {marker} " + colored_rows = ( + f"{marker_color}{_BOLD}{marker_prefix}{_RESET}" + f"{text_color}{_BOLD}{console_rows[0][len(marker_prefix):]}" + ) + if len(console_rows) > 1: + colored_rows += "\n" + "\n".join( + f"{text_color}{_BOLD}{row}" for row in console_rows[1:] + ) + self.console.write(f"{colored_rows}{_RESET}\n") else: self.console.write("\n".join(console_rows) + "\n") self.console.flush() diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index 5a4185b3..451214dc 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -152,19 +152,30 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if dependency_id not in selected: selected[dependency_id] = dependency pending_dependencies.append(dependency_id) - for candidate in selected.values(): + policy_errors: list[str] = [] + for candidate in sorted( + selected.values(), + key=lambda item: item.metadata.id if item.metadata else "", + ): assert candidate.metadata is not None if request.profile == "offline": prohibited = set(candidate.metadata.capabilities).intersection(_OFFLINE_PROHIBITED_CAPABILITIES) if prohibited: names = ", ".join(sorted(capability.value for capability in prohibited)) - raise PlanError(f"offline profile prohibits network-capable collector {candidate.metadata.id}: {names}") + policy_errors.append( + f"{candidate.metadata.id}: offline profile prohibits network-capable collector: {names}" + ) if sys.platform not in candidate.metadata.supported_platforms: - raise PlanError(f"{candidate.metadata.id} does not support {sys.platform}") + policy_errors.append(f"{candidate.metadata.id}: does not support {sys.platform}") missing_capabilities = set(candidate.metadata.capabilities) - set(request.approved_capabilities) if missing_capabilities: required = ", ".join(sorted(capability.value for capability in missing_capabilities)) - raise PlanError(f"{candidate.metadata.id} requires unapproved capabilities: {required}") + policy_errors.append( + f"{candidate.metadata.id}: requires unapproved capabilities: {required}" + ) + if policy_errors: + details = "\n".join(f"- {error}" for error in policy_errors) + raise PlanError(f"selected collector policy validation failed:\n{details}") elevated_collectors: list[str] = [] for candidate in selected.values(): diff --git a/tests/test_logging.py b/tests/test_logging.py index 8bd76f42..b1bbf4a0 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -77,7 +77,8 @@ def isatty(self) -> bool: self.assertNotIn("Presentation", contents) self.assertNotIn("raw", contents) self.assertTrue(all(" | " in line for line in contents.splitlines())) - self.assertIn("\u00d7 typed event", console.getvalue()) + self.assertIn("\033[91m\033[1m \u00d7 ", console.getvalue()) + self.assertIn("typed event", console.getvalue()) self.assertIn("\u256d", console.getvalue()) first_row = contents.splitlines()[0] self.assertRegex(first_row, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") @@ -185,6 +186,33 @@ def isatty(self) -> bool: self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) + def test_application_logging_colors_info_text_white_and_debug_text_gray(self) -> None: + """Status markers retain their severity color while message text stays readable.""" + + class TerminalBuffer(io.StringIO): + def isatty(self) -> bool: + return True + + with tempfile.TemporaryDirectory() as temporary: + console = TerminalBuffer() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(level="DEBUG", file_enabled=False, color_enabled=True), + console=console, + ) + logger.event("INFO", "info message") + logger.event("DEBUG", "debug message") + + output = console.getvalue() + self.assertIn( + "\033[96m\033[1m \u25cf \033[0m\033[97m\033[1minfo message", + output, + ) + self.assertIn( + "\033[90m\033[1m \u00b7 \033[0m\033[90m\033[1mdebug message", + output, + ) + def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" events: list[tuple[str, str, dict[str, Any]]] = [] diff --git a/tests/test_planning.py b/tests/test_planning.py index 9649518f..31e8c9dc 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -232,6 +232,56 @@ def test_offline_profile_rejects_network_collectors_even_with_explicit_approval( RunRequest(profile="offline", include=(collector_id,), approved_capabilities=(Capability.NETWORK,)), ) + def test_policy_error_reports_every_selected_collector(self) -> None: + """Planning reports all selected capability failures in one deterministic result.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + core_directory = root / "core" / "system" + core_directory.mkdir(parents=True) + (root / "plugins").mkdir() + + for filename, capability in ( + ("a_subprocess", "Capability.SUBPROCESS"), + ("b_network", "Capability.NETWORK"), + ("c_subprocess", "Capability.SUBPROCESS"), + ): + source = delayed_collector_source(filename, 0.0).replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ).replace( + ' supported_platforms=("win32",),', + ' supported_platforms=("win32",),\n' + f" capabilities=({capability},),\n" + + ( + " network_access=NetworkAccess.LOCAL,\n" + if capability == "Capability.NETWORK" + else "" + ), + ) + (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") + + with self.assertRaisesRegex( + PlanError, + "selected collector policy validation failed", + ) as rejected: + build_plan(preflight(root), RunRequest()) + + message = str(rejected.exception) + self.assertLess( + message.index("core.system.a_subprocess"), + message.index("core.system.b_network"), + ) + self.assertLess( + message.index("core.system.b_network"), + message.index("core.system.c_subprocess"), + ) + for collector_id in ( + "core.system.a_subprocess", + "core.system.b_network", + "core.system.c_subprocess", + ): + self.assertIn(collector_id, message) + def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) -> None: """Collection consent must explain requested evidence before creating a workspace.""" with tempfile.TemporaryDirectory() as temporary: From ae67ee9fbafb3a82e21dbbae43ed05dae143c0a6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 00:14:45 +0300 Subject: [PATCH 250/316] fix(cli): simplify command output presentation Render summaries and command errors as redacted line-by-line console output instead of bordered panels. Add actionable capability approval guidance and update startup documentation to include required standard-profile approvals. --- README.md | 6 ++--- docs/CONFIGURATION.md | 2 +- logicytics/module/logging.py | 51 +++++------------------------------ logicytics/module/planner.py | 14 +++++++++- tests/test_logging.py | 4 +-- tests/test_planning.py | 4 +++ tests/test_test_runner_cli.py | 2 +- 7 files changed, 31 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 6fd537f0..4adc74d5 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ python -m logicytics preflight When preflight is clean, plan before collecting: ```powershell -python -m logicytics plan --profile standard -python -m logicytics run --profile standard --acknowledge-authorization +python -m logicytics plan --profile standard --allow-capability subprocess --allow-capability network +python -m logicytics run --profile standard --allow-capability subprocess --allow-capability network --acknowledge-authorization ``` Use the bundled runner for the complete dynamically discovered test suite: @@ -51,7 +51,7 @@ Each run follows a simple, auditable path: `request → validated plan → isolated collectors → normalized artifacts → manifest → package` -The console keeps status and progress easy to scan. Detailed command output is boxed; application events are written separately in an aligned, human-readable log. Evidence and package metadata remain with the run they describe. +The console keeps status and progress easy to scan. Command output is rendered line by line; application events are written separately in an aligned, human-readable log. Evidence and package metadata remain with the run they describe. Sysinternals is enabled by default. Logicytics detects an existing installation or securely downloads and extracts the official archive to its dedicated application location. Set `maintenance.sysinternals_enabled: false` in `logicytics.yaml` to opt out completely. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 63ca59cf..f39ba398 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,7 +17,7 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. -Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping. File rows use the aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Console-only command results use the same rounded summary panels as AIBrain and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. +Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping. File rows use the aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. ## Collector settings diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index b4fa5e16..da0850d8 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -40,7 +40,6 @@ } _RESET = "\033[0m" _BOLD = "\033[1m" -_BOX_COLOR = "\033[96m" _FILE_LOG_LINE_WIDTH = 140 _TIME_WIDTH = 19 _SEVERITY_WIDTH = 9 @@ -221,52 +220,16 @@ def separator(self) -> None: self.raw("") def box(self, title: str, lines: Iterable[str]) -> None: - """Render console-only output in an AIBrain-style summary panel.""" - width = self._console_width() - content_width = width - 4 - content: list[str] = [] + """Render console-only output as plain redacted lines.""" + rows = [redact_text(title)] for line in lines: - safe = redact_text(line) - wrapped = textwrap.wrap( - safe, - width=content_width, - break_long_words=False, - break_on_hyphens=False, - ) or [""] - for row in wrapped: - content.extend( - row[index:index + content_width] - for index in range(0, len(row), content_width) - ) - inner = width - 2 - if self._supports_unicode(): - horizontal, vertical = "\u2500", "\u2502" - top_left, top_right = "\u256d", "\u256e" - middle_left, middle_right = "\u251c", "\u2524" - bottom_left, bottom_right = "\u2570", "\u256f" - else: - horizontal, vertical = "-", "|" - top_left = top_right = "+" - middle_left = middle_right = "+" - bottom_left = bottom_right = "+" - top = top_left + horizontal * inner + top_right - middle = middle_left + horizontal * inner + middle_right - bottom = bottom_left + horizontal * inner + bottom_right - rows = [ - "", - top, - f"{vertical} {title[:content_width].center(content_width)} {vertical}", - middle, - *(f"{vertical} {line:<{content_width}} {vertical}" for line in content), - bottom, - "", - ] + rows.extend( + f" {redact_text(row)}" + for row in line.splitlines() or [""] + ) with self._lock: if self.settings.console_enabled: - if self.settings.color_enabled and self.console.isatty(): - self.console.write(f"{_BOX_COLOR}{chr(10).join(rows)}{_RESET}\n") - else: - self.console.write("\n".join(rows) + "\n") + self.console.write("\n".join(rows) + "\n") self.console.flush() def dispatch(self, messages: Iterable[str]) -> None: diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index 451214dc..e9f02f1e 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -153,6 +153,7 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: selected[dependency_id] = dependency pending_dependencies.append(dependency_id) policy_errors: list[str] = [] + missing_capabilities_for_request: set[Capability] = set() for candidate in sorted( selected.values(), key=lambda item: item.metadata.id if item.metadata else "", @@ -169,13 +170,24 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: policy_errors.append(f"{candidate.metadata.id}: does not support {sys.platform}") missing_capabilities = set(candidate.metadata.capabilities) - set(request.approved_capabilities) if missing_capabilities: + missing_capabilities_for_request.update(missing_capabilities) required = ", ".join(sorted(capability.value for capability in missing_capabilities)) policy_errors.append( f"{candidate.metadata.id}: requires unapproved capabilities: {required}" ) if policy_errors: details = "\n".join(f"- {error}" for error in policy_errors) - raise PlanError(f"selected collector policy validation failed:\n{details}") + hint = "" + if missing_capabilities_for_request: + flags = " ".join( + f"--allow-capability {capability.value}" + for capability in sorted( + missing_capabilities_for_request, + key=lambda capability: capability.value, + ) + ) + hint = f"\nRerun with: {flags}" + raise PlanError(f"selected collector policy validation failed:\n{details}{hint}") elevated_collectors: list[str] = [] for candidate in selected.values(): diff --git a/tests/test_logging.py b/tests/test_logging.py index b1bbf4a0..b57ad985 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -79,7 +79,7 @@ def isatty(self) -> bool: self.assertTrue(all(" | " in line for line in contents.splitlines())) self.assertIn("\033[91m\033[1m \u00d7 ", console.getvalue()) self.assertIn("typed event", console.getvalue()) - self.assertIn("\u256d", console.getvalue()) + self.assertNotIn("\u256d", console.getvalue()) first_row = contents.splitlines()[0] self.assertRegex(first_row, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") self.assertLessEqual(max(map(len, contents.splitlines())), 140) @@ -182,7 +182,7 @@ def isatty(self) -> bool: rows = console.getvalue().splitlines() self.assertTrue(rows[0].startswith(" * ")) self.assertTrue(rows[1].startswith(" ")) - self.assertTrue(any(row.startswith("+") for row in rows)) + self.assertEqual(["Summary", " finished"], rows[-2:]) self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) diff --git a/tests/test_planning.py b/tests/test_planning.py index 31e8c9dc..27403ac6 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -281,6 +281,10 @@ def test_policy_error_reports_every_selected_collector(self) -> None: "core.system.c_subprocess", ): self.assertIn(collector_id, message) + self.assertIn( + "Rerun with: --allow-capability network --allow-capability subprocess", + message, + ) def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) -> None: """Collection consent must explain requested evidence before creating a workspace.""" diff --git a/tests/test_test_runner_cli.py b/tests/test_test_runner_cli.py index bf4cfe97..ff71a1ac 100644 --- a/tests/test_test_runner_cli.py +++ b/tests/test_test_runner_cli.py @@ -10,7 +10,7 @@ class TestRunnerCliTests(unittest.TestCase): """Presentation and result handling for the dynamically discovered test command.""" def test_runner_reports_discovered_suite_through_the_application_console(self) -> None: - """A successful dynamic run renders a boxed summary and returns a CI-success status.""" + """A successful dynamic run renders a line-based summary and returns CI success.""" result = MagicMock() result.failures = () result.errors = () From 0ad3ddb310e886a65942b37df05a7d6024f98891 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 00:21:04 +0300 Subject: [PATCH 251/316] docs: document all Logicytics CLI usage Add parser-aligned tables for global, shared, run, maintenance, installer, and test-runner flags. Document profiles, capabilities, execution modes, legacy aliases, direct collectors, reruns, utilities, and practical command examples. --- README.md | 288 ++++++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 27 ++++- 2 files changed, 311 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4adc74d5..67f27a37 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,294 @@ Use the bundled runner for the complete dynamically discovered test suite: python -m logicytics.cli.tests ``` +## CLI reference + +Run these commands from the repository root. The normal CLI must run inside +the virtual environment created by the installer. Both module entry points are +equivalent: + +```powershell +.venv\Scripts\python.exe -m logicytics --help +.venv\Scripts\python.exe -m logicytics.cli --help +``` + +The CLI has three standalone utility actions and seven subcommands: + +| Invocation | Purpose | +|---|---| +| python -m logicytics --usage | Show local interaction statistics and create flag_usage.svg. | +| python -m logicytics --modes | Print the machine-readable execution-mode matrix. | +| python -m logicytics --match TEXT | Suggest the closest documented action for natural-language input. | +| python -m logicytics preflight | Discover and validate collectors. | +| python -m logicytics debug | Write and print environment and preflight diagnostics. | +| python -m logicytics plan | Resolve a dependency-safe plan without collecting evidence. | +| python -m logicytics run | Execute the plan in isolated workers and publish a run. | +| python -m logicytics collector COLLECTOR_ID | Execute one exact collector independently. | +| python -m logicytics update | Check Git and optionally apply an update. | +| python -m logicytics dev | Run repository integrity checks and manage the local manifest. | + +Standalone actions cannot be combined with a subcommand. Use --help after any +action for the parser-generated help. + +### Global flags + +These flags go before the action name: + +| Flag | Description | +|---|---| +| -h, --help | Show help and exit. | +| --config PATH | Load configuration from PATH instead of the root logicytics.yaml. | +| --usage | Show local interaction statistics and create the usage graph. | +| --modes | Print the complete execution-mode and collector matrix. | +| --match TEXT | Match free-form text to a documented action using the local matcher. | + +### Shared collector flags + +These flags are parser-exposed on every subcommand. They affect collector +selection and planning on preflight, debug, plan, run, and collector; update +and dev accept them for CLI consistency but do not execute collection. + +| Flag | Description | +|---|---| +| --profile {minimal,standard,deep,offline} | Select built-in collector membership and access policy. | +| --include COLLECTOR_ID | Include one exact dotted collector ID; repeat as needed. | +| --exclude COLLECTOR_ID | Exclude one exact dotted collector ID; repeat as needed. | +| --plugins | Enable all valid opt-in plugins for the selected profile. | +| --mods | Enable valid sidecar-declared scripts from MODS. | +| --workers COUNT | Bound concurrent isolated workers; configuration supplies the default. | +| --allow-capability CAPABILITY | Approve one declared access capability; repeat for each capability. | + +Capabilities require explicit approval even for shipped collectors: + +| Capability | Permission | +|---|---| +| filesystem_read | Read files through the filesystem boundary. | +| filesystem_write | Write outside a collector private workspace where permitted. | +| registry_read | Read Windows registry data through the registry adapter. | +| subprocess | Launch an approved child process through the process adapter. | +| network | Use approved local or remote network access. | +| packet_capture | Capture raw packets; network approval is not enough. | +| browser_data | Read approved local browser profile data. | +| sensitive_files | Read the selected sensitive-file surface. | +| private_keys | Read approved private-key material. | +| elevated_privileges | Select collectors requiring administrator privileges. | + +For the current standard profile, the usual approval command is: + +```powershell +--allow-capability subprocess --allow-capability network +``` + +If planning finds missing approvals, it reports every affected collector and +prints a copyable rerun command. The approvals must also be supplied to run. + +### Profiles + +| Profile | Selection policy | +|---|---| +| minimal | Essential local system, memory, and storage inventory. | +| standard | Shipped core collectors declaring standard membership. | +| deep | Extended declared inventory; sensitive and privileged work still needs explicit selection and approval. | +| offline | Local-only inventory; network and packet-capture collectors are rejected. | + +## Preflight + +Preflight is read-only. It validates collector filenames, AST boundaries, +metadata, lifecycle contracts, and isolated runtime metadata, then classifies +valid, invalid, and quarantined extensions. + +```powershell +python -m logicytics preflight +python -m logicytics preflight --include plugin.example --plugins +python -m logicytics preflight --mods +``` + +It returns 0 when there are no blocking invalid collectors and 2 when a +blocking validation failure exists. It does not collect evidence. + +## Planning + +Planning resolves profile membership, explicit selections, dependencies, +capabilities, platform support, offline policy, and administrator requirements. +It does not launch collection workers or create a run-owned evidence tree. + +```powershell +python -m logicytics plan --profile minimal +python -m logicytics plan --profile standard --allow-capability subprocess --allow-capability network +python -m logicytics plan --profile standard --include core.system.system_info --exclude core.network.network_identity +python -m logicytics plan --profile standard --plugins --allow-capability subprocess +python -m logicytics plan --profile standard --mods --allow-capability subprocess +python -m logicytics plan --profile offline +``` + +The output is line-based and lists collectors in dependency-safe order. + +## Collection runs + +run performs preflight, builds the plan, starts isolated workers, retains +manifests, logs, and artifacts, and normally creates a verified ZIP package. + +| Run flag | Description | +|---|---| +| --mode MODE | Select a named execution mode. | +| --sequential | Run one isolated worker at a time. | +| --parallel | Use bounded parallel execution; at least two workers are required. | +| --rerun-from PATH | Rerun explicit IDs from a finalized run directory or manifest.json. | +| --performance-check | Run serially and write per-collector duration measurements. | +| --no-package | Finalize evidence without creating a ZIP package. | +| --reboot | Schedule a reboot only after successful packaged output. | +| --shutdown | Schedule a shutdown only after successful packaged output. | +| --acknowledge-authorization | Confirm authorization; required before collection starts. | +| --interactive | Pause at the final status in an interactive command window. | + +Conflicting execution modes are rejected. Reboot and shutdown are mutually +exclusive. + +### Named execution modes + +| Mode | Profile | Scheduling | Extensions | +|---|---|---|---| +| standard | standard | Sequential | None | +| balanced | standard | Configured bounded parallelism | None | +| quick | minimal | Configured bounded parallelism | None | +| thorough | deep | Configured bounded parallelism | None | +| offline | offline | Configured bounded parallelism | None | +| extensions | standard | Configured bounded parallelism | All valid plugins and MODs | +| non-python | standard | Configured bounded parallelism | Non-Python MODs only | +| performance | standard | Sequential | Duration report enabled | + +```powershell +python -m logicytics run --mode quick --acknowledge-authorization +python -m logicytics run --mode balanced --allow-capability subprocess --allow-capability network --acknowledge-authorization +python -m logicytics run --mode performance --allow-capability subprocess --allow-capability network --acknowledge-authorization +``` + +### Legacy run aliases + +| Alias | Equivalent mode | +|---|---| +| --default | standard | +| --threaded | balanced | +| --minimal | quick | +| --depth | thorough | +| --modded | extensions | +| --nopy | non-python | +| --performance-check | performance | + +### Direct collector runs + +collector runs one exact ID and cannot be combined with profile, include, +exclude, plugin, or MOD selection flags. + +```powershell +python -m logicytics collector core.system.system_info --acknowledge-authorization +python -m logicytics collector core.network.network_adapters --allow-capability subprocess --acknowledge-authorization +python -m logicytics collector core.network.network_identity --allow-capability network --acknowledge-authorization +``` + +Direct collector runs also accept --allow-capability, --no-package, and +--interactive, and retain the same manifest and artifact contracts. + +### Reruns + +Reruns require a finalized prior run and at least one explicit --include. The +included IDs must exist in the original resolved plan: + +```powershell +python -m logicytics run --rerun-from output/data/run- --include core.system.system_info --acknowledge-authorization +``` + +The rerun receives a new run ID and separate evidence directory. + +## Diagnostics and maintenance + +### Debug + +debug prints diagnostic JSON and writes output/data/debug_logs/debug.json. It +includes configuration, environment, Python/runtime details, Sysinternals +state, preflight counts, and maintenance checks. + +```powershell +python -m logicytics debug +python -m logicytics --config C:\path\to\logicytics.yaml debug +``` + +### Update + +update checks Git availability and repository state. It is read-only unless +--apply is supplied. + +| Flag | Description | +|---|---| +| --apply | Run git pull after repository checks. | +| --launch-action {preflight,debug,dev} | Choose a safe action to launch after a successful update check. | +| --new-window | Launch that action in a separate visible Windows console; requires --launch-action. | +| --performance-check | Compatibility flag accepted by the parser; update does not collect evidence. | + +```powershell +python -m logicytics update +python -m logicytics update --apply +python -m logicytics update --launch-action preflight --new-window +``` + +### Developer checks + +dev runs repository organization and integrity checks without collecting +evidence. + +| Flag | Description | +|---|---| +| --write-manifest | Write the reviewed local integrity manifest. | +| --next-version VERSION | Version to record; required for non-interactive writes. | +| --interactive | Show checks and prompt before changing the local manifest. | + +```powershell +python -m logicytics dev +python -m logicytics dev --write-manifest --next-version 4.0.0 +python -m logicytics dev --interactive +``` + +## Companion CLI scripts + +### Installer + +The installer is the only Logicytics command designed to run outside a virtual +environment. It creates or reuses the environment and writes the root +logicytics.yaml template. + +```powershell +python -m logicytics.cli.installer +python -m logicytics.cli.installer --environment .venv +python -m logicytics.cli.installer --environment C:\Tools\logicytics-venv +python -m logicytics.cli.installer --overwrite-config +``` + +| Flag | Description | +|---|---| +| --environment PATH | Virtual environment directory; default is .venv. Relative paths use the repository root. | +| --overwrite-config | Replace the root YAML template with default values. | + +After installation, use .venv\Scripts\python.exe for normal commands. + +### Test runner + +The test runner discovers the complete tests package, logs suite lifecycle, +and returns CI-friendly status. It requires the managed virtual environment. + +```powershell +python -m logicytics.cli.tests +python -m logicytics.cli.tests --verbosity 0 +python -m logicytics.cli.tests --verbosity 1 +python -m logicytics.cli.tests --verbosity 2 +``` + +| Flag | Description | +|---|---| +| --verbosity {0,1,2} | Forward unittest output verbosity; default is 2. | + +The test runner returns 0 for a passing suite, 1 for failures/errors, and 2 +when the environment or test presentation cannot be prepared. + ## What to expect Each run follows a simple, auditable path: diff --git a/SECURITY.md b/SECURITY.md index 9b54be81..bb5aa280 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,10 +2,29 @@ ## Supported versions -| Version | Security support | Release date | -|-----------------|------------------|--------------| -| 4.0.x | Supported | 2026-08-31 | -| 3.x and earlier | Unsupported | — | +| Version | Security support | Release date | +|---------|------------------|--------------------| +| 4.0.x | Supported | September XX, 2026 | +| 3.6.x | Partial Support | July 26, 2025 | +| 3.5.x | Partial Support | July 26, 2025 | +| 3.4.x | Partial Support | January 3, 2025 | +| 3.3.x | Unsupported | January 3, 2025 | +| 3.2.x | Unsupported | December 19, 2024 | +| 3.1.x | Unsupported | December 11, 2024 | +| 3.0.x | Unsupported | December 6, 2024 | +| 2.5.x | Unsupported | November 25, 2024 | +| 2.4.x | Unsupported | November 12, 2024 | +| 2.3.x | Unsupported | September 21, 2024 | +| 2.2.x | Unsupported | September 9, 2024 | +| 2.1.x | Unsupported | August 29, 2024 | +| 2.0.x | Unsupported | August 25, 2024 | +| 1.6.x | Unsupported | June 18, 2024 | +| 1.5.x | Unsupported | June 10, 2024 | +| 1.4.x | Unsupported | May 30, 2024 | +| 1.3.x | Unsupported | May 21, 2024 | +| 1.2.x | Unsupported | May 16, 2024 | +| 1.1.x | Unsupported | May 10, 2024 | +| 1.0.x | Unsupported | May 4, 2024 | Only the current v4 release line receives security fixes. Upgrade through the documented [migration boundary](docs/MIGRATION.md); do not run an unsupported checkout From 5a9dfbb391826ec1c4cb5ff3b92e7f0fec8182c2 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 00:25:33 +0300 Subject: [PATCH 252/316] fix(cli): approve standard core capabilities Automatically approve the subprocess and network capabilities required by the built-in standard core profile while keeping direct, deep, extension, and MOD requests explicit. Document the default and cover the capability boundary with CLI request tests. --- README.md | 215 +++++++++++++++++++------------------ logicytics/cli/commands.py | 24 ++++- tests/test_cli.py | 55 ++++++++++ 3 files changed, 183 insertions(+), 111 deletions(-) diff --git a/README.md b/README.md index 67f27a37..28f33304 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ python -m logicytics preflight When preflight is clean, plan before collecting: ```powershell -python -m logicytics plan --profile standard --allow-capability subprocess --allow-capability network -python -m logicytics run --profile standard --allow-capability subprocess --allow-capability network --acknowledge-authorization +python -m logicytics plan --profile standard +python -m logicytics run --profile standard --acknowledge-authorization ``` Use the bundled runner for the complete dynamically discovered test suite: @@ -58,18 +58,18 @@ equivalent: The CLI has three standalone utility actions and seven subcommands: -| Invocation | Purpose | -|---|---| -| python -m logicytics --usage | Show local interaction statistics and create flag_usage.svg. | -| python -m logicytics --modes | Print the machine-readable execution-mode matrix. | -| python -m logicytics --match TEXT | Suggest the closest documented action for natural-language input. | -| python -m logicytics preflight | Discover and validate collectors. | -| python -m logicytics debug | Write and print environment and preflight diagnostics. | -| python -m logicytics plan | Resolve a dependency-safe plan without collecting evidence. | -| python -m logicytics run | Execute the plan in isolated workers and publish a run. | -| python -m logicytics collector COLLECTOR_ID | Execute one exact collector independently. | -| python -m logicytics update | Check Git and optionally apply an update. | -| python -m logicytics dev | Run repository integrity checks and manage the local manifest. | +| Invocation | Purpose | +|---------------------------------------------|-------------------------------------------------------------------| +| python -m logicytics --usage | Show local interaction statistics and create flag_usage.svg. | +| python -m logicytics --modes | Print the machine-readable execution-mode matrix. | +| python -m logicytics --match TEXT | Suggest the closest documented action for natural-language input. | +| python -m logicytics preflight | Discover and validate collectors. | +| python -m logicytics debug | Write and print environment and preflight diagnostics. | +| python -m logicytics plan | Resolve a dependency-safe plan without collecting evidence. | +| python -m logicytics run | Execute the plan in isolated workers and publish a run. | +| python -m logicytics collector COLLECTOR_ID | Execute one exact collector independently. | +| python -m logicytics update | Check Git and optionally apply an update. | +| python -m logicytics dev | Run repository integrity checks and manage the local manifest. | Standalone actions cannot be combined with a subcommand. Use --help after any action for the parser-generated help. @@ -78,13 +78,13 @@ action for the parser-generated help. These flags go before the action name: -| Flag | Description | -|---|---| -| -h, --help | Show help and exit. | -| --config PATH | Load configuration from PATH instead of the root logicytics.yaml. | -| --usage | Show local interaction statistics and create the usage graph. | -| --modes | Print the complete execution-mode and collector matrix. | -| --match TEXT | Match free-form text to a documented action using the local matcher. | +| Flag | Description | +|---------------|----------------------------------------------------------------------| +| -h, --help | Show help and exit. | +| --config PATH | Load configuration from PATH instead of the root logicytics.yaml. | +| --usage | Show local interaction statistics and create the usage graph. | +| --modes | Print the complete execution-mode and collector matrix. | +| --match TEXT | Match free-form text to a documented action using the local matcher. | ### Shared collector flags @@ -92,48 +92,49 @@ These flags are parser-exposed on every subcommand. They affect collector selection and planning on preflight, debug, plan, run, and collector; update and dev accept them for CLI consistency but do not execute collection. -| Flag | Description | -|---|---| -| --profile {minimal,standard,deep,offline} | Select built-in collector membership and access policy. | -| --include COLLECTOR_ID | Include one exact dotted collector ID; repeat as needed. | -| --exclude COLLECTOR_ID | Exclude one exact dotted collector ID; repeat as needed. | -| --plugins | Enable all valid opt-in plugins for the selected profile. | -| --mods | Enable valid sidecar-declared scripts from MODS. | -| --workers COUNT | Bound concurrent isolated workers; configuration supplies the default. | -| --allow-capability CAPABILITY | Approve one declared access capability; repeat for each capability. | - -Capabilities require explicit approval even for shipped collectors: - -| Capability | Permission | -|---|---| -| filesystem_read | Read files through the filesystem boundary. | -| filesystem_write | Write outside a collector private workspace where permitted. | -| registry_read | Read Windows registry data through the registry adapter. | -| subprocess | Launch an approved child process through the process adapter. | -| network | Use approved local or remote network access. | -| packet_capture | Capture raw packets; network approval is not enough. | -| browser_data | Read approved local browser profile data. | -| sensitive_files | Read the selected sensitive-file surface. | -| private_keys | Read approved private-key material. | -| elevated_privileges | Select collectors requiring administrator privileges. | - -For the current standard profile, the usual approval command is: - -```powershell ---allow-capability subprocess --allow-capability network -``` +| Flag | Description | +|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| +| --profile {minimal,standard,deep,offline} | Select built-in collector membership and access policy. | +| --include COLLECTOR_ID | Include one exact dotted collector ID; repeat as needed. | +| --exclude COLLECTOR_ID | Exclude one exact dotted collector ID; repeat as needed. | +| --plugins | Enable all valid opt-in plugins for the selected profile. | +| --mods | Enable valid sidecar-declared scripts from MODS. | +| --workers COUNT | Bound concurrent isolated workers; configuration supplies the default. | +| --allow-capability CAPABILITY | Approve one declared access capability; repeat for each capability. The standard built-in CLI profile supplies its baseline capabilities automatically. | + +The standard built-in CLI profile automatically approves the `subprocess` and +`network` capabilities required by its shipped core collectors. The typed +`RunRequest` API remains fail-closed by default. Other capabilities, opt-in +plugins, MODS, deep/offline profiles, and direct collector runs require +explicit `--allow-capability` flags: + +| Capability | Permission | +|---------------------|---------------------------------------------------------------| +| filesystem_read | Read files through the filesystem boundary. | +| filesystem_write | Write outside a collector private workspace where permitted. | +| registry_read | Read Windows registry data through the registry adapter. | +| subprocess | Launch an approved child process through the process adapter. | +| network | Use approved local or remote network access. | +| packet_capture | Capture raw packets; network approval is not enough. | +| browser_data | Read approved local browser profile data. | +| sensitive_files | Read the selected sensitive-file surface. | +| private_keys | Read approved private-key material. | +| elevated_privileges | Select collectors requiring administrator privileges. | + +To make the standard approvals explicit, or to approve capabilities for an +extension/direct run, use `--allow-capability ` flag If planning finds missing approvals, it reports every affected collector and prints a copyable rerun command. The approvals must also be supplied to run. ### Profiles -| Profile | Selection policy | -|---|---| -| minimal | Essential local system, memory, and storage inventory. | -| standard | Shipped core collectors declaring standard membership. | -| deep | Extended declared inventory; sensitive and privileged work still needs explicit selection and approval. | -| offline | Local-only inventory; network and packet-capture collectors are rejected. | +| Profile | Selection policy | +|----------|---------------------------------------------------------------------------------------------------------| +| minimal | Essential local system, memory, and storage inventory. | +| standard | Shipped core collectors declaring standard membership. | +| deep | Extended declared inventory; sensitive and privileged work still needs explicit selection and approval. | +| offline | Local-only inventory; network and packet-capture collectors are rejected. | ## Preflight @@ -158,7 +159,7 @@ It does not launch collection workers or create a run-owned evidence tree. ```powershell python -m logicytics plan --profile minimal -python -m logicytics plan --profile standard --allow-capability subprocess --allow-capability network +python -m logicytics plan --profile standard python -m logicytics plan --profile standard --include core.system.system_info --exclude core.network.network_identity python -m logicytics plan --profile standard --plugins --allow-capability subprocess python -m logicytics plan --profile standard --mods --allow-capability subprocess @@ -172,52 +173,52 @@ The output is line-based and lists collectors in dependency-safe order. run performs preflight, builds the plan, starts isolated workers, retains manifests, logs, and artifacts, and normally creates a verified ZIP package. -| Run flag | Description | -|---|---| -| --mode MODE | Select a named execution mode. | -| --sequential | Run one isolated worker at a time. | -| --parallel | Use bounded parallel execution; at least two workers are required. | -| --rerun-from PATH | Rerun explicit IDs from a finalized run directory or manifest.json. | -| --performance-check | Run serially and write per-collector duration measurements. | -| --no-package | Finalize evidence without creating a ZIP package. | -| --reboot | Schedule a reboot only after successful packaged output. | -| --shutdown | Schedule a shutdown only after successful packaged output. | -| --acknowledge-authorization | Confirm authorization; required before collection starts. | -| --interactive | Pause at the final status in an interactive command window. | +| Run flag | Description | +|-----------------------------|---------------------------------------------------------------------| +| --mode MODE | Select a named execution mode. | +| --sequential | Run one isolated worker at a time. | +| --parallel | Use bounded parallel execution; at least two workers are required. | +| --rerun-from PATH | Rerun explicit IDs from a finalized run directory or manifest.json. | +| --performance-check | Run serially and write per-collector duration measurements. | +| --no-package | Finalize evidence without creating a ZIP package. | +| --reboot | Schedule a reboot only after successful packaged output. | +| --shutdown | Schedule a shutdown only after successful packaged output. | +| --acknowledge-authorization | Confirm authorization; required before collection starts. | +| --interactive | Pause at the final status in an interactive command window. | Conflicting execution modes are rejected. Reboot and shutdown are mutually exclusive. ### Named execution modes -| Mode | Profile | Scheduling | Extensions | -|---|---|---|---| -| standard | standard | Sequential | None | -| balanced | standard | Configured bounded parallelism | None | -| quick | minimal | Configured bounded parallelism | None | -| thorough | deep | Configured bounded parallelism | None | -| offline | offline | Configured bounded parallelism | None | -| extensions | standard | Configured bounded parallelism | All valid plugins and MODs | -| non-python | standard | Configured bounded parallelism | Non-Python MODs only | -| performance | standard | Sequential | Duration report enabled | +| Mode | Profile | Scheduling | Extensions | +|-------------|----------|--------------------------------|----------------------------| +| standard | standard | Sequential | None | +| balanced | standard | Configured bounded parallelism | None | +| quick | minimal | Configured bounded parallelism | None | +| thorough | deep | Configured bounded parallelism | None | +| offline | offline | Configured bounded parallelism | None | +| extensions | standard | Configured bounded parallelism | All valid plugins and MODs | +| non-python | standard | Configured bounded parallelism | Non-Python MODs only | +| performance | standard | Sequential | Duration report enabled | ```powershell python -m logicytics run --mode quick --acknowledge-authorization -python -m logicytics run --mode balanced --allow-capability subprocess --allow-capability network --acknowledge-authorization -python -m logicytics run --mode performance --allow-capability subprocess --allow-capability network --acknowledge-authorization +python -m logicytics run --mode balanced --acknowledge-authorization +python -m logicytics run --mode performance --acknowledge-authorization ``` ### Legacy run aliases -| Alias | Equivalent mode | -|---|---| -| --default | standard | -| --threaded | balanced | -| --minimal | quick | -| --depth | thorough | -| --modded | extensions | -| --nopy | non-python | -| --performance-check | performance | +| Alias | Equivalent mode | +|---------------------|-----------------| +| --default | standard | +| --threaded | balanced | +| --minimal | quick | +| --depth | thorough | +| --modded | extensions | +| --nopy | non-python | +| --performance-check | performance | ### Direct collector runs @@ -262,12 +263,12 @@ python -m logicytics --config C:\path\to\logicytics.yaml debug update checks Git availability and repository state. It is read-only unless --apply is supplied. -| Flag | Description | -|---|---| -| --apply | Run git pull after repository checks. | -| --launch-action {preflight,debug,dev} | Choose a safe action to launch after a successful update check. | -| --new-window | Launch that action in a separate visible Windows console; requires --launch-action. | -| --performance-check | Compatibility flag accepted by the parser; update does not collect evidence. | +| Flag | Description | +|---------------------------------------|-------------------------------------------------------------------------------------| +| --apply | Run git pull after repository checks. | +| --launch-action {preflight,debug,dev} | Choose a safe action to launch after a successful update check. | +| --new-window | Launch that action in a separate visible Windows console; requires --launch-action. | +| --performance-check | Compatibility flag accepted by the parser; update does not collect evidence. | ```powershell python -m logicytics update @@ -280,11 +281,11 @@ python -m logicytics update --launch-action preflight --new-window dev runs repository organization and integrity checks without collecting evidence. -| Flag | Description | -|---|---| -| --write-manifest | Write the reviewed local integrity manifest. | -| --next-version VERSION | Version to record; required for non-interactive writes. | -| --interactive | Show checks and prompt before changing the local manifest. | +| Flag | Description | +|------------------------|------------------------------------------------------------| +| --write-manifest | Write the reviewed local integrity manifest. | +| --next-version VERSION | Version to record; required for non-interactive writes. | +| --interactive | Show checks and prompt before changing the local manifest. | ```powershell python -m logicytics dev @@ -307,10 +308,10 @@ python -m logicytics.cli.installer --environment C:\Tools\logicytics-venv python -m logicytics.cli.installer --overwrite-config ``` -| Flag | Description | -|---|---| +| Flag | Description | +|--------------------|------------------------------------------------------------------------------------------| | --environment PATH | Virtual environment directory; default is .venv. Relative paths use the repository root. | -| --overwrite-config | Replace the root YAML template with default values. | +| --overwrite-config | Replace the root YAML template with default values. | After installation, use .venv\Scripts\python.exe for normal commands. @@ -326,8 +327,8 @@ python -m logicytics.cli.tests --verbosity 1 python -m logicytics.cli.tests --verbosity 2 ``` -| Flag | Description | -|---|---| +| Flag | Description | +|---------------------|--------------------------------------------------| | --verbosity {0,1,2} | Forward unittest output verbosity; default is 2. | The test runner returns 0 for a passing suite, 1 for failures/errors, and 2 diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index bfcc42a1..70a14087 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -43,6 +43,9 @@ from logicytics.platform_adapters import process_adapter +_STANDARD_CORE_CAPABILITIES = (Capability.SUBPROCESS, Capability.NETWORK) + + class CLI: """Translate command-line arguments into validated application requests.""" @@ -260,6 +263,22 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: else include_arguments ) + requested_capabilities = tuple( + Capability(value) + for value in getattr(arguments, "allow_capability", ()) + ) + default_capabilities = ( + _STANDARD_CORE_CAPABILITIES + if profile == "standard" + and not plugins_enabled + and not enable_mods + and not selection_only + else () + ) + approved_capabilities = tuple(dict.fromkeys( + (*default_capabilities, *requested_capabilities) + )) + return RunRequest( profile=profile, include=includes, @@ -274,10 +293,7 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: "acknowledge_authorization", False, ), - approved_capabilities=tuple( - Capability(value) - for value in getattr(arguments, "allow_capability", ()) - ), + approved_capabilities=approved_capabilities, performance_check=performance_check, rerun_from=parent_run_id, output_policy=( diff --git a/tests/test_cli.py b/tests/test_cli.py index ed587989..9fe1f49c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch from logicytics.cli import CLI, cli_methods, main +from logicytics.contracts import Capability from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.platform_adapters import process_adapter @@ -25,6 +26,60 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) + def test_standard_core_requests_approve_baseline_capabilities(self) -> None: + """The standard built-in CLI profile includes its required baseline access.""" + parser = cli_methods.parser() + + request = cli_methods.request( + parser.parse_args(["run", "--profile", "standard"]), + default_workers=4, + ) + self.assertEqual( + (Capability.SUBPROCESS, Capability.NETWORK), + request.approved_capabilities, + ) + + with_extra_approval = cli_methods.request( + parser.parse_args([ + "run", + "--profile", + "standard", + "--allow-capability", + "network", + "--allow-capability", + "filesystem_read", + ]), + default_workers=4, + ) + self.assertEqual( + ( + Capability.SUBPROCESS, + Capability.NETWORK, + Capability.FILESYSTEM_READ, + ), + with_extra_approval.approved_capabilities, + ) + + def test_baseline_capabilities_remain_explicit_for_extensions_and_direct_runs(self) -> None: + """Opt-in and direct execution keep the capability boundary fail-closed.""" + parser = cli_methods.parser() + restricted_requests = ( + ["run", "--profile", "standard", "--plugins"], + ["run", "--profile", "standard", "--mods"], + ["run", "--mode", "extensions"], + ["run", "--mode", "non-python"], + ["run", "--profile", "deep"], + ["collector", "core.network.network_adapters"], + ) + + for arguments in restricted_requests: + with self.subTest(arguments=arguments): + request = cli_methods.request( + parser.parse_args(arguments), + default_workers=4, + ) + self.assertEqual((), request.approved_capabilities) + def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" parser = cli_methods.parser() From 65df116e9e8789e7cdc926e8133f1e233d16d3bb Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 00:44:32 +0300 Subject: [PATCH 253/316] feat(logging): add detailed command and collector lifecycle logs Humanize lifecycle events on the console while preserving stable machine-readable file messages. Add command timing, run and packaging context, and per-collector worker, retry, resource, artifact, and terminal status telemetry. --- README.md | 2 +- docs/CONFIGURATION.md | 2 +- logicytics/cli/commands.py | 118 +++++++++++++++++++++++++++++--- logicytics/module/logging.py | 11 ++- logicytics/module/runtime.py | 128 +++++++++++++++++++++++++++++++++-- tests/test_cli.py | 6 ++ tests/test_logging.py | 2 + 7 files changed, 248 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 28f33304..e0e4935a 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ Each run follows a simple, auditable path: `request → validated plan → isolated collectors → normalized artifacts → manifest → package` -The console keeps status and progress easy to scan. Command output is rendered line by line; application events are written separately in an aligned, human-readable log. Evidence and package metadata remain with the run they describe. +The console keeps status and progress easy to scan. Lifecycle events are shown with readable messages, while application events are written separately in an aligned, human-readable log with duration and execution context. Evidence and package metadata remain with the run they describe. Sysinternals is enabled by default. Logicytics detects an existing installation or securely downloads and extracts the official archive to its dedicated application location. Set `maintenance.sysinternals_enabled: false` in `logicytics.yaml` to opt out completely. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f39ba398..3d7b1166 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,7 +17,7 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. -Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping. File rows use the aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. +Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping; machine-oriented lifecycle names such as `collector_finished` are shown as readable text such as `Collector finished`. File rows keep the stable aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Command lifecycle events include terminal status and elapsed time, while run and collector events add worker, retry, artifact, and resource context. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. ## Collector settings diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 70a14087..d454006b 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -10,6 +10,7 @@ import platform import sys from pathlib import Path +from time import perf_counter from typing import Literal from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest @@ -668,6 +669,7 @@ def main(argv: list[str] | None = None) -> int: root = cli_methods.project_root() application_logger = None + command_started_at: float | None = None try: configuration = load_config(root, arguments.config) @@ -687,6 +689,27 @@ def main(argv: list[str] | None = None) -> int: source="logicytics.cli", command=arguments.command, ) + started_at = perf_counter() + command_started_at = started_at + + def finish_command( + exit_code: int, + *, + status: str | None = None, + **fields: int | float | str, + ) -> int: + """Record one command's terminal status and elapsed time.""" + application_logger.event( + "INFO" if exit_code == 0 else "WARNING", + "command_finished", + source="logicytics.cli", + command=str(arguments.command), + exit_code=exit_code, + status=status or ("succeeded" if exit_code == 0 else "failed"), + duration_seconds=round(perf_counter() - started_at, 3), + **fields, + ) + return exit_code history_path = ( configuration.runtime.output_root @@ -734,7 +757,12 @@ def main(argv: list[str] | None = None) -> int: ) ) - return 0 if match.matched_flag is not None else 1 + exit_code = 0 if match.matched_flag is not None else 1 + return finish_command( + exit_code, + status="matched" if exit_code == 0 else "not_matched", + matched_flag=match.matched_flag or "none", + ) if arguments.command == "usage": statistics = usage_statistics( @@ -760,12 +788,28 @@ def main(argv: list[str] | None = None) -> int: ) ) - return 0 + return finish_command(0, status="usage_written") if arguments.command == "modes": + modes_configuration_hash = configuration.fingerprint() + application_logger.event( + "INFO", + "preflight_started", + source="logicytics.cli", + configuration_hash=modes_configuration_hash, + purpose="mode_matrix", + ) report = preflight( root, - configuration_hash=configuration.fingerprint(), + configuration_hash=modes_configuration_hash, + ) + application_logger.event( + "INFO", + "preflight_finished", + source="logicytics.cli", + valid_collectors=len(report.valid), + invalid_collectors=len(report.invalid), + purpose="mode_matrix", ) payload = mode_matrix( @@ -780,7 +824,12 @@ def main(argv: list[str] | None = None) -> int: ) ) - return 0 + return finish_command( + 0, + status="mode_matrix_written", + valid_collectors=len(report.valid), + invalid_collectors=len(report.invalid), + ) application_logger.event( "INFO", @@ -810,7 +859,13 @@ def main(argv: list[str] | None = None) -> int: sysinternals = ensure_sysinternals(root, configuration.maintenance).to_dict() cli_methods.render_preflight(application_logger, validation, sysinternals) - return 0 if not validation["invalid"] else 2 + exit_code = 0 if not validation["invalid"] else 2 + return finish_command( + exit_code, + status="validated" if exit_code == 0 else "invalid_collectors", + valid_collectors=len(validation["valid"]), + invalid_collectors=len(validation["invalid"]), + ) if arguments.command == "debug": payload: dict[str, object] = { @@ -867,7 +922,13 @@ def main(argv: list[str] | None = None) -> int: ) ) - return 0 if not report.invalid else 2 + exit_code = 0 if not report.invalid else 2 + return finish_command( + exit_code, + status="diagnostics_written" if exit_code == 0 else "invalid_collectors", + valid_collectors=len(report.valid), + invalid_collectors=len(report.invalid), + ) if arguments.command == "update": if arguments.new_window != ( @@ -907,7 +968,12 @@ def main(argv: list[str] | None = None) -> int: sort_keys=True, ) ) - return 2 + return finish_command( + 2, + status="git_unavailable", + git_available=git.returncode == 0, + is_repository=is_repository, + ) pulled = process_adapter.run( ["git", "pull"], @@ -954,14 +1020,22 @@ def main(argv: list[str] | None = None) -> int: ) ) - return 0 if update_succeeded else 1 + return finish_command( + 0 if update_succeeded else 1, + status="updated" if update_succeeded else "update_failed", + applied=arguments.apply, + ) if arguments.command == "dev": - return cli_methods.run_developer_action( + exit_code = cli_methods.run_developer_action( root, configuration, arguments, ) + return finish_command( + exit_code, + status="checks_completed" if exit_code == 0 else "checks_failed", + ) request = cli_methods.request( arguments, @@ -975,6 +1049,9 @@ def main(argv: list[str] | None = None) -> int: include_count=len(request.include), exclude_count=len(request.exclude), approved_capabilities=len(request.approved_capabilities), + enable_plugins=request.enable_plugins, + enable_mods=request.enable_mods, + max_workers=request.max_workers, ) plan = build_plan( report, @@ -1004,7 +1081,7 @@ def main(argv: list[str] | None = None) -> int: collectors=len(plan.collectors), ) - return 0 + return finish_command(0, status="plan_rendered", collectors=len(plan.collectors)) outcome = RunSupervisor( root, @@ -1082,7 +1159,12 @@ def main(argv: list[str] | None = None) -> int: except EOFError: pass - return exit_code + return finish_command( + exit_code, + status=outcome.manifest.status.value, + run_id=outcome.manifest.run_id, + collectors=len(outcome.manifest.collectors), + ) except ( LogicyticsError, @@ -1095,9 +1177,23 @@ def main(argv: list[str] | None = None) -> int: application_logger.event( "EXCEPTION", str(error), + source="logicytics.cli", + command=str(arguments.command), error_type=type(error).__name__, ) + if command_started_at is not None: + application_logger.event( + "ERROR", + "command_finished", + source="logicytics.cli", + command=str(arguments.command), + exit_code=2, + status="failed", + duration_seconds=round(perf_counter() - command_started_at, 3), + error_type=type(error).__name__, + ) + if application_logger is not None: application_logger.box("Command error", (str(error),)) else: diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index da0850d8..b6b65a9b 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -48,6 +48,7 @@ _MIN_CONSOLE_WIDTH = 60 _RIGHT_EDGE_MARGIN = 4 _RECORD_START = re.compile(rb"(?m)^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") +_EVENT_NAME = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)+$") _LOGGER_LOCK = RLock() _APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} _EVENT_LOGGERS: dict[tuple[Path, str, str | None], "FileEventLogger"] = {} @@ -164,6 +165,13 @@ def _console_rows(cls, marker: str, message: str) -> tuple[str, ...]: rows.append(current_prefix + remaining) return tuple(rows) + @staticmethod + def _console_message(message: str) -> str: + """Make machine-oriented lifecycle event names readable on the console.""" + if _EVENT_NAME.fullmatch(message): + return message.replace("_", " ").capitalize() + return message + def event(self, level: str, message: str, **fields: int | float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" normalized = level.upper() @@ -179,6 +187,7 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: for key, value in sorted(safe_fields.items()) ) rendered_message = safe_message + suffix + console_message = self._console_message(safe_message) + suffix rows = self._rows(normalized, source, rendered_message) with self._lock: if self.settings.file_enabled: @@ -189,7 +198,7 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: marker, marker_color, text_color = _LEVEL_PRESENTATION[normalized] if not self._supports_unicode(): marker = {"\u25cf": "*", "\u00d7": "X", "\u00b7": "."}.get(marker, marker) - console_rows = self._console_rows(marker, rendered_message) + console_rows = self._console_rows(marker, console_message) if self.settings.color_enabled and self.console.isatty(): marker_prefix = f" {marker} " colored_rows = ( diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index f68d9842..4ee4cb0e 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -32,6 +32,7 @@ CollectorMetadata, CollectorResult, CollectorStatus, + EventLogger, OutputPolicy, PostRunAction, ResourceClass, @@ -762,12 +763,25 @@ def run(self, plan: RunPlan) -> RunOutcome: write_manifest(manifest_path, manifest) manifest.status = RunStatus.RUNNING write_manifest(manifest_path, manifest) - run_logger.event("info", "run_started", collectors=len(plan.collectors)) + run_started_at = monotonic() + run_logger.event( + "info", + "run_started", + collectors=len(plan.collectors), + profile=plan.request.profile, + max_workers=plan.request.max_workers, + plan_fingerprint=plan.fingerprint, + output_policy=plan.request.output_policy.value, + ) application_logger.event( "INFO", "run_started", + source="logicytics.runtime", run_id=run_id, collectors=len(plan.collectors), + profile=plan.request.profile, + max_workers=plan.request.max_workers, + plan_fingerprint=plan.fingerprint, ) records = {record.id: record for record in manifest.collectors} @@ -782,10 +796,17 @@ def run(self, plan: RunPlan) -> RunOutcome: manifest_path, records, run_logger, + application_logger, ) except KeyboardInterrupt: cancellation_file.touch() run_logger.event("warning", "run_cancellation_requested") + application_logger.event( + "WARNING", + "run_cancellation_requested", + source="logicytics.runtime", + run_id=run_id, + ) self._cancel_records(records, manifest, manifest_path, "run cancelled by user") manifest.finalize_status() @@ -796,6 +817,13 @@ def run(self, plan: RunPlan) -> RunOutcome: and plan.request.output_policy is OutputPolicy.PACKAGE ) if should_package: + run_logger.event("info", "run_packaging_started") + application_logger.event( + "INFO", + "run_packaging_started", + source="logicytics.runtime", + run_id=run_id, + ) try: package_path, hash_path = package_manifest(run_directory, manifest, manifest_path) run_logger.event( @@ -807,6 +835,7 @@ def run(self, plan: RunPlan) -> RunOutcome: application_logger.event( "INFO", "run_packaged", + source="logicytics.runtime", run_id=run_id, package_path=str(package_path), ) @@ -817,20 +846,61 @@ def run(self, plan: RunPlan) -> RunOutcome: application_logger.event( "ERROR", "run_packaging_failed", + source="logicytics.runtime", run_id=run_id, error_type=type(error).__name__, ) + else: + packaging_skip_reason = ( + "manifest_only_output" + if plan.request.output_policy is OutputPolicy.MANIFEST_ONLY + else "configuration_disabled" + ) + run_logger.event( + "info", + "run_packaging_skipped", + reason=packaging_skip_reason, + ) + application_logger.event( + "INFO", + "run_packaging_skipped", + source="logicytics.runtime", + run_id=run_id, + reason=packaging_skip_reason, + ) write_manifest(manifest_path, manifest) - run_logger.event("info", "run_finished", status=manifest.status.value, artifacts=manifest.total_artifact_bytes) + status_counts = { + status: sum(1 for record in manifest.collectors if record.status == status) + for status in sorted({record.status for record in manifest.collectors}) + } + run_logger.event( + "info", + "run_finished", + status=manifest.status.value, + artifacts=manifest.total_artifact_bytes, + duration_seconds=round(monotonic() - run_started_at, 3), + **{f"{status}_count": count for status, count in status_counts.items()}, + ) application_logger.event( "INFO", "run_finished", + source="logicytics.runtime", run_id=run_id, status=manifest.status.value, artifacts=manifest.total_artifact_bytes, + duration_seconds=round(monotonic() - run_started_at, 3), + **{f"{status}_count": count for status, count in status_counts.items()}, ) if plan.request.post_run_action is not PostRunAction.NONE: self._execute_post_run_action(plan.request.post_run_action, manifest, run_logger) + application_logger.event( + "WARNING", + "post_run_action_scheduled", + source="logicytics.runtime", + run_id=run_id, + action=plan.request.post_run_action.value, + delay_seconds=60, + ) return RunOutcome(manifest=manifest, run_directory=run_directory, manifest_path=manifest_path) @staticmethod @@ -893,6 +963,7 @@ def _supervise( manifest_path: Path, records: dict[str, CollectorRecord], run_logger: FileEventLogger, + application_logger: EventLogger, ) -> None: """Schedule bounded isolated workers and contain each terminal failure.""" pending = list(plan.collectors) @@ -1052,7 +1123,24 @@ def _supervise( metadata.resource_class, output_budget, ) - run_logger.event("info", "collector_started", collector_id=metadata.id) + start_fields = { + "collector_id": metadata.id, + "attempt": records[metadata.id].attempt_count, + "worker_pid": process.pid or 0, + "timeout_seconds": metadata.timeout_seconds, + "maximum_memory_bytes": metadata.maximum_memory_bytes, + "output_budget_bytes": output_budget, + "resource_class": metadata.resource_class.value, + "parallel_safe": metadata.parallel_safe, + } + run_logger.event("info", "collector_started", **start_fields) + application_logger.event( + "INFO", + "collector_started", + source="logicytics.runtime", + run_id=run_id, + **start_fields, + ) write_manifest(manifest_path, manifest) if not metadata.parallel_safe: break @@ -1066,11 +1154,14 @@ def _supervise( worker = active.pop(collector_id, None) if worker is not None: worker.process.join(timeout=1) - self._apply_worker_result(records[collector_id], _result_from_dict(message["result"]), worker) + record = records[collector_id] + self._apply_worker_result(record, _result_from_dict(message["result"]), worker) artifact_bytes = self._collector_artifact_bytes(artifact_root, collector_id) committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) - if not self._schedule_retry( + attempt_status = record.status + attempt_duration = record.duration_seconds or 0.0 + retry_scheduled = self._schedule_retry( candidates[collector_id], records[collector_id], artifact_bytes, @@ -1078,8 +1169,31 @@ def _supervise( retry_not_before, cancellation_file, run_logger, - ): - run_logger.event("info", "collector_finished", collector_id=collector_id) + ) + finish_level = "warning" if retry_scheduled else "info" + finish_fields = { + "collector_id": collector_id, + "status": record.status, + "attempt_status": attempt_status, + "attempt": record.attempt_count, + "duration_seconds": record.duration_seconds or 0.0, + "attempt_duration_seconds": attempt_duration, + "artifact_count": len(record.artifacts), + "artifact_bytes": artifact_bytes, + "event_count": record.event_count, + "peak_memory_bytes": record.peak_memory_bytes, + "worker_exit_code": worker.process.exitcode or 0, + "termination_reason": record.termination_reason or "unknown", + "retry_scheduled": retry_scheduled, + } + run_logger.event(finish_level, "collector_finished", **finish_fields) + application_logger.event( + "WARNING" if retry_scheduled else "INFO", + "collector_finished", + source="logicytics.runtime", + run_id=run_id, + **finish_fields, + ) write_manifest(manifest_path, manifest) for collector_id, worker in tuple(active.items()): diff --git a/tests/test_cli.py b/tests/test_cli.py index 9fe1f49c..bce6760c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -266,6 +266,12 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( "Command error", ("--new-window and --launch-action must be provided together",), ) + event_messages = [ + call.args[1] + for call in error_logger.event.call_args_list + if len(call.args) > 1 + ] + self.assertIn("command_finished", event_messages) def test_new_window_launcher_uses_current_interpreter_without_a_shell( self, diff --git a/tests/test_logging.py b/tests/test_logging.py index b57ad985..fb4ba4bf 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -202,6 +202,7 @@ def isatty(self) -> bool: ) logger.event("INFO", "info message") logger.event("DEBUG", "debug message") + logger.event("INFO", "collector_finished", collector_id="core.system.system_info") output = console.getvalue() self.assertIn( @@ -212,6 +213,7 @@ def isatty(self) -> bool: "\033[90m\033[1m \u00b7 \033[0m\033[90m\033[1mdebug message", output, ) + self.assertIn("Collector finished collector_id=", output) def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" From 9f6e85d085ac4f59bb66b82a778195f0370b0c13 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 00:58:32 +0300 Subject: [PATCH 254/316] fix: including git autosquash conventions Signed-off-by: DefinetlyNotAI --- .github/script/validate-commit-msg.sh | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/script/validate-commit-msg.sh b/.github/script/validate-commit-msg.sh index 7c333f4a..73aeada4 100644 --- a/.github/script/validate-commit-msg.sh +++ b/.github/script/validate-commit-msg.sh @@ -5,7 +5,7 @@ set -eu commit_msg_file="$1" first_line="$(head -n 1 "$commit_msg_file")" -pattern='^((fixup|squash)! )?(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([A-Za-z0-9._/-]+\))?!?: .+$' +pattern='^((fixup|squash|amend|reword)! )?(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([A-Za-z0-9._/-]+\))?!?: .+$' if printf '%s\n' "$first_line" | grep -Eq "$pattern"; then exit 0 @@ -36,13 +36,19 @@ Allowed types: style test -Examples: +Expected Conventional Commits format: + + : + (): + !: + ()!: + +Autosquash commits are also accepted: - feat: add artifact compression - fix(cli): handle missing configuration - refactor(core): simplify collector discovery - docs: update setup instructions - feat(api)!: remove legacy endpoint + fixup! + squash! + amend! + reword! EOF From ae9ca371986e3048c0ad0414a30436667dc03aa5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 01:13:54 +0300 Subject: [PATCH 255/316] feat(capabilities): default to declared access with blocking policy Treat CollectorMetadata.capabilities as the executable request contract and add explicit CLI/configuration blocks. Reject undeclared audit events with stable CAPABILITY_DECLARATION_MISMATCH diagnostics, propagate policy into Python MOD runners, require shipped core metadata declarations, and expose capability-aware lifecycle logging and documentation. --- README.md | 59 ++++++------ core/memory/memory_snapshot.py | 1 + core/process/memory_map.py | 1 + core/storage/logical_drives.py | 1 + core/storage/volume_details.py | 1 + core/system/system_info.py | 1 + docs/CONFIGURATION.md | 10 +- docs/MODS.md | 25 +++-- docs/V4_RELEASE.md | 6 +- logicytics/__init__.py | 3 - logicytics/cli/commands.py | 34 +++---- logicytics/contracts.py | 11 ++- logicytics/module/api.py | 6 +- logicytics/module/configuration.py | 31 +++++- logicytics/module/discovery.py | 6 ++ logicytics/module/errors.py | 16 ++++ logicytics/module/manifest.py | 2 +- logicytics/module/mod_runner.py | 12 ++- logicytics/module/planner.py | 22 ++--- logicytics/module/runtime.py | 148 +++++++++++++++++++++++++---- tests/fixtures/collectors.py | 2 +- tests/test_cli.py | 45 ++++----- tests/test_configuration.py | 32 +++++++ tests/test_mods.py | 19 ++-- tests/test_planning.py | 61 ++++++------ tests/test_preflight.py | 21 ++++ tests/test_runtime.py | 3 +- tests/test_worker_security.py | 31 +++--- 28 files changed, 416 insertions(+), 194 deletions(-) diff --git a/README.md b/README.md index e0e4935a..e9a4236f 100644 --- a/README.md +++ b/README.md @@ -92,40 +92,39 @@ These flags are parser-exposed on every subcommand. They affect collector selection and planning on preflight, debug, plan, run, and collector; update and dev accept them for CLI consistency but do not execute collection. -| Flag | Description | -|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------| -| --profile {minimal,standard,deep,offline} | Select built-in collector membership and access policy. | -| --include COLLECTOR_ID | Include one exact dotted collector ID; repeat as needed. | -| --exclude COLLECTOR_ID | Exclude one exact dotted collector ID; repeat as needed. | -| --plugins | Enable all valid opt-in plugins for the selected profile. | -| --mods | Enable valid sidecar-declared scripts from MODS. | -| --workers COUNT | Bound concurrent isolated workers; configuration supplies the default. | -| --allow-capability CAPABILITY | Approve one declared access capability; repeat for each capability. The standard built-in CLI profile supplies its baseline capabilities automatically. | - -The standard built-in CLI profile automatically approves the `subprocess` and -`network` capabilities required by its shipped core collectors. The typed -`RunRequest` API remains fail-closed by default. Other capabilities, opt-in -plugins, MODS, deep/offline profiles, and direct collector runs require -explicit `--allow-capability` flags: +| Flag | Description | +|-------------------------------------------|------------------------------------------------------------------------------------| +| --profile {minimal,standard,deep,offline} | Select built-in collector membership and access policy. | +| --include COLLECTOR_ID | Include one exact dotted collector ID; repeat as needed. | +| --exclude COLLECTOR_ID | Exclude one exact dotted collector ID; repeat as needed. | +| --plugins | Enable all valid opt-in plugins for the selected profile. | +| --mods | Enable valid sidecar-declared scripts from MODS. | +| --workers COUNT | Bound concurrent isolated workers; configuration supplies the default. | +| --block-capability CAPABILITY | Block one capability requested by selected collectors; repeat for each capability. | + +Collector metadata declares the capabilities each script requests. Declared +capabilities run by default unless blocked with `--block-capability` or the +`runtime.blocked_capabilities` configuration setting. The offline profile still +enforces its network restrictions, and authorization is still required before +collection starts: | Capability | Permission | |---------------------|---------------------------------------------------------------| | filesystem_read | Read files through the filesystem boundary. | | filesystem_write | Write outside a collector private workspace where permitted. | | registry_read | Read Windows registry data through the registry adapter. | -| subprocess | Launch an approved child process through the process adapter. | -| network | Use approved local or remote network access. | -| packet_capture | Capture raw packets; network approval is not enough. | -| browser_data | Read approved local browser profile data. | +| subprocess | Launch a child process through the process adapter. | +| network | Use declared local or remote network access. | +| packet_capture | Capture raw packets; network declaration is not enough. | +| browser_data | Read declared local browser profile data. | | sensitive_files | Read the selected sensitive-file surface. | -| private_keys | Read approved private-key material. | +| private_keys | Read declared private-key material. | | elevated_privileges | Select collectors requiring administrator privileges. | -To make the standard approvals explicit, or to approve capabilities for an -extension/direct run, use `--allow-capability ` flag - -If planning finds missing approvals, it reports every affected collector and -prints a copyable rerun command. The approvals must also be supplied to run. +If planning finds a blocked capability, it reports every affected collector and +prints the blocking policy in the error. A script that accesses a capability +without declaring it in `CollectorMetadata.capabilities` is refused by the +isolated worker with the unique `CAPABILITY_DECLARATION_MISMATCH` diagnostic. ### Profiles @@ -161,8 +160,8 @@ It does not launch collection workers or create a run-owned evidence tree. python -m logicytics plan --profile minimal python -m logicytics plan --profile standard python -m logicytics plan --profile standard --include core.system.system_info --exclude core.network.network_identity -python -m logicytics plan --profile standard --plugins --allow-capability subprocess -python -m logicytics plan --profile standard --mods --allow-capability subprocess +python -m logicytics plan --profile standard --plugins +python -m logicytics plan --profile standard --mods python -m logicytics plan --profile offline ``` @@ -227,11 +226,11 @@ exclude, plugin, or MOD selection flags. ```powershell python -m logicytics collector core.system.system_info --acknowledge-authorization -python -m logicytics collector core.network.network_adapters --allow-capability subprocess --acknowledge-authorization -python -m logicytics collector core.network.network_identity --allow-capability network --acknowledge-authorization +python -m logicytics collector core.network.network_adapters --acknowledge-authorization +python -m logicytics collector core.network.network_identity --acknowledge-authorization ``` -Direct collector runs also accept --allow-capability, --no-package, and +Direct collector runs also accept --block-capability, --no-package, and --interactive, and retain the same manifest and artifact contracts. ### Reruns diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index ee0af50b..ed5c6795 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -25,6 +25,7 @@ def metadata(cls) -> CollectorMetadata: description="Captures aggregate physical, virtual, and page-file memory statistics.", author="Logicytics", supported_platforms=("win32",), + capabilities=(), default_profiles=("minimal", "standard", "deep", "offline"), timeout_seconds=10, maximum_output_bytes=64 * 1024, diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 47df20d8..67c3e464 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -31,6 +31,7 @@ def metadata(cls) -> CollectorMetadata: description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.", author="Logicytics", supported_platforms=("win32",), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), + capabilities=(), timeout_seconds=90, maximum_output_bytes=64 * 1024 * 1024, ) diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index f8e4737d..fbfa9a7e 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -72,6 +72,7 @@ def metadata(cls) -> CollectorMetadata: description="Records mounted logical-drive types and aggregate capacity metadata.", author="Logicytics", supported_platforms=("win32",), + capabilities=(), default_profiles=("minimal", "standard", "deep", "offline"), timeout_seconds=10, maximum_output_bytes=64 * 1024, diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index ce6ee6f0..889055d5 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -76,6 +76,7 @@ def metadata(cls) -> CollectorMetadata: output_media_types=("application/json",), description="Exports mounted drive type, filesystem, label, and capacity metadata.", author="Logicytics", supported_platforms=("win32",), default_profiles=("deep",), timeout_seconds=15, + capabilities=(), maximum_output_bytes=128 * 1024, ) diff --git a/core/system/system_info.py b/core/system/system_info.py index d16da410..8353a31e 100644 --- a/core/system/system_info.py +++ b/core/system/system_info.py @@ -28,6 +28,7 @@ def metadata(cls) -> CollectorMetadata: description="Collects a bounded operating-system and hardware inventory.", author="Logicytics", supported_platforms=("win32",), + capabilities=(), default_profiles=("minimal", "standard", "deep", "offline"), timeout_seconds=15, maximum_output_bytes=128 * 1024, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3d7b1166..9de0bacd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -10,12 +10,12 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` | Section | Fields | |---------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `runtime` | `output_root`, `default_max_workers`, `maximum_workers`, `package_completed_runs`, `maximum_run_output_bytes` | +| `runtime` | `output_root`, `default_max_workers`, `maximum_workers`, `package_completed_runs`, `maximum_run_output_bytes`, `blocked_capabilities` | | `interaction` | `history_enabled`, `similarity_threshold`, `model_name`, `model_debug` | | `maintenance` | `remote_manifest_url`, `remote_manifest_sha256`, `local_manifest_path`, `minimum_python`, `recommended_python`, `sysinternals_enabled`, `sysinternals_download_url` | | `logging` | `level`, `console_enabled`, `color_enabled`, `file_enabled`, `maximum_bytes`, `delete_previous`, `retention_days` | -`runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. +`runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. `runtime.blocked_capabilities` is a mapping of capability names to booleans; true values disable matching metadata requests for the run. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping; machine-oriented lifecycle names such as `collector_finished` are shown as readable text such as `Collector finished`. File rows keep the stable aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Command lifecycle events include terminal status and elapsed time, while run and collector events add worker, retry, artifact, and resource context. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. @@ -42,7 +42,11 @@ The `collectors` mapping is keyed by a validated collector ID. Shipped collector "default_max_workers": 4, "maximum_workers": 16, "package_completed_runs": true, - "maximum_run_output_bytes": 4294967296 + "maximum_run_output_bytes": 4294967296, + "blocked_capabilities": { + "network": false, + "subprocess": false + } }, "interaction": { "history_enabled": false, diff --git a/docs/MODS.md b/docs/MODS.md index a160ffb9..c769b1f3 100644 --- a/docs/MODS.md +++ b/docs/MODS.md @@ -46,13 +46,12 @@ This example is intentionally bounded and non-sensitive: ``` The ID must be `mod.`. Legacy adapters must request the -`subprocess` capability, and the user must approve it with -`--allow-capability subprocess`. Other access must also be declared and -approved. Python MODs are audit-confined to their private workspace by default. +`subprocess` capability. Other access must also be declared and remains +subject to the active blocked-capability policy. Python MODs are audit-confined +to their private workspace by default. PowerShell, batch, and executable MODs must additionally declare -`filesystem_write` and the user must explicitly approve it with -`--allow-capability filesystem_write`, because Python cannot enforce filesystem -boundaries inside native child processes. A Python MOD needs that capability +`filesystem_write`, because Python cannot enforce filesystem boundaries inside +native child processes. A Python MOD needs that capability only when intentionally writing outside its workspace. Invalid or missing sidecars quarantine the mod; selecting all mods then fails preflight before any collector launches. @@ -82,10 +81,10 @@ SHA-256 sidecar. ## Security boundary Mods are untrusted, opt-in local code. Worker/process isolation, a private -workspace, capability approval, bounded resources, packaging allowlists, and -process-tree termination contain engine state and evidence publication. Python -MOD mutation attempts outside the workspace fail unless `filesystem_write` was -approved. Native MOD types require that explicit high-risk approval before -preflight accepts them. Approval does not grant access beyond the Windows -account running Logicytics, nor replace Windows ACLs or an AppContainer. Review -a mod and its declared capabilities before approving execution. +workspace, declared-capability enforcement, bounded resources, packaging +allowlists, and process-tree termination contain engine state and evidence +publication. Python MOD mutation attempts outside the workspace fail unless +`filesystem_write` was declared. Native MOD types require that declaration +before preflight accepts them. A configured or command-line block still +overrides a declaration. Review a mod and its declared capabilities before +executing it. diff --git a/docs/V4_RELEASE.md b/docs/V4_RELEASE.md index 4c5c17ab..8e090851 100644 --- a/docs/V4_RELEASE.md +++ b/docs/V4_RELEASE.md @@ -92,8 +92,10 @@ sizes, and hashes before returning or opening evidence. Collection cannot start without explicit authorization acknowledgement. Sensitive, network, packet, browser, private-key, filesystem, subprocess, -registry, and elevation capabilities are declared per collector and require -approval. Elevated selection also requires a live administrator check. +registry, and elevation capabilities are declared per collector and run by +default unless blocked by request flags or runtime configuration. Sensitive +selection still requires authorization acknowledgement, and elevated selection +also requires a live administrator check. One collector failure does not abort independent work, but its failure cannot be hidden by a successful package. The manifest records operation, platform error, diff --git a/logicytics/__init__.py b/logicytics/__init__.py index f0901133..133365ba 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -23,9 +23,6 @@ Specialty, ValidationResult, ) -from logicytics.module.api import CollectorFailureSnapshot, CollectorSnapshot, RunSnapshot, load_configuration, \ - open_artifact, plan_run, query_run, read_artifact, run_collection - _APPLICATION_EXPORTS = frozenset({ "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "open_artifact", "plan_run", "query_run", "read_artifact", diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index d454006b..d4bedd9c 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -44,9 +44,6 @@ from logicytics.platform_adapters import process_adapter -_STANDARD_CORE_CAPABILITIES = (Capability.SUBPROCESS, Capability.NETWORK) - - class CLI: """Translate command-line arguments into validated application requests.""" @@ -89,7 +86,11 @@ def project_root() -> Path: return Path(__file__).resolve().parents[2] @staticmethod - def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: + def request( + arguments: argparse.Namespace, + default_workers: int, + configured_blocked_capabilities: tuple[Capability, ...] = (), + ) -> RunRequest: """Build an immutable run request while enforcing mode and rerun conflicts.""" legacy_flags = { flag: getattr(arguments, flag, False) @@ -264,20 +265,12 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: else include_arguments ) - requested_capabilities = tuple( + requested_blocked_capabilities = tuple( Capability(value) - for value in getattr(arguments, "allow_capability", ()) - ) - default_capabilities = ( - _STANDARD_CORE_CAPABILITIES - if profile == "standard" - and not plugins_enabled - and not enable_mods - and not selection_only - else () + for value in getattr(arguments, "block_capability", ()) ) - approved_capabilities = tuple(dict.fromkeys( - (*default_capabilities, *requested_capabilities) + blocked_capabilities = tuple(dict.fromkeys( + (*configured_blocked_capabilities, *requested_blocked_capabilities) )) return RunRequest( @@ -294,7 +287,7 @@ def request(arguments: argparse.Namespace, default_workers: int) -> RunRequest: "acknowledge_authorization", False, ), - approved_capabilities=approved_capabilities, + blocked_capabilities=blocked_capabilities, performance_check=performance_check, rerun_from=parent_run_id, output_policy=( @@ -355,11 +348,11 @@ def parser() -> argparse.ArgumentParser: help="Bound concurrent isolated workers to this positive count.", ) subparser.add_argument( - "--allow-capability", + "--block-capability", action="append", default=[], choices=[capability.value for capability in Capability], - help="Approve an access capability requested by the selected collectors.", + help="Block a declared capability for the selected collectors; repeat as needed.", ) if command == "run": subparser.add_argument( @@ -1040,6 +1033,7 @@ def finish_command( request = cli_methods.request( arguments, configuration.runtime.default_max_workers, + configuration.runtime.blocked_capabilities, ) application_logger.event( "INFO", @@ -1048,7 +1042,7 @@ def finish_command( profile=request.profile, include_count=len(request.include), exclude_count=len(request.exclude), - approved_capabilities=len(request.approved_capabilities), + blocked_capabilities=len(request.blocked_capabilities), enable_plugins=request.enable_plugins, enable_mods=request.enable_mods, max_workers=request.max_workers, diff --git a/logicytics/contracts.py b/logicytics/contracts.py index 33738a34..e3489873 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -100,7 +100,7 @@ class Specialty(StrEnum): class Capability(StrEnum): - """Explicitly approved platform access a collector may request.""" + """Platform access a collector must declare before requesting it.""" FILESYSTEM_READ = "filesystem_read" FILESYSTEM_WRITE = "filesystem_write" @@ -490,6 +490,9 @@ class RunRequest: non_python_only: bool = False max_workers: int = 4 acknowledge_authorization: bool = False + blocked_capabilities: tuple[Capability, ...] = () + """Capabilities globally disabled for this request, unless absent from metadata.""" + # Retained for manifest/API compatibility; declared metadata is now allowed by default. approved_capabilities: tuple[Capability, ...] = () performance_check: bool = False rerun_from: str | None = None @@ -530,6 +533,12 @@ def __post_init__(self) -> None: raise ValueError("request max_workers must be an integer from 1 to 64") if self.performance_check and self.max_workers != 1: raise ValueError("request performance_check requires max_workers=1") + if not isinstance(self.blocked_capabilities, tuple) or not all( + isinstance(capability, Capability) for capability in self.blocked_capabilities + ): + raise ValueError("request blocked_capabilities must be a tuple of Capability values") + if len(set(self.blocked_capabilities)) != len(self.blocked_capabilities): + raise ValueError("request blocked_capabilities must not contain duplicates") if not isinstance(self.approved_capabilities, tuple) or not all( isinstance(capability, Capability) for capability in self.approved_capabilities ): diff --git a/logicytics/module/api.py b/logicytics/module/api.py index f27b0277..3f10836a 100644 --- a/logicytics/module/api.py +++ b/logicytics/module/api.py @@ -7,7 +7,7 @@ import os import re import sys -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime from math import isfinite from pathlib import Path, PurePosixPath @@ -110,6 +110,10 @@ def plan_run( root, settings = _configuration(project_root, configuration, config_path) if not isinstance(request, RunRequest): raise PlanError("request must be an immutable RunRequest instance") + blocked_capabilities = tuple(dict.fromkeys( + (*settings.runtime.blocked_capabilities, *request.blocked_capabilities) + )) + request = replace(request, blocked_capabilities=blocked_capabilities) if request.max_workers > settings.runtime.maximum_workers: raise PlanError("requested workers exceed configured maximum_workers") return build_plan(preflight(root, configuration_hash=settings.fingerprint()), request) diff --git a/logicytics/module/configuration.py b/logicytics/module/configuration.py index 22bcd33e..d1df2977 100644 --- a/logicytics/module/configuration.py +++ b/logicytics/module/configuration.py @@ -11,6 +11,7 @@ from types import MappingProxyType from typing import Any, Mapping +from logicytics.contracts import Capability from logicytics.module.errors import PlanError from logicytics.module.redaction import redact_mapping @@ -27,6 +28,7 @@ }) _RUNTIME_FIELDS = frozenset({ "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", + "blocked_capabilities", }) _INTERACTION_FIELDS = frozenset({"history_enabled", "similarity_threshold", "model_name", "model_debug"}) _MAINTENANCE_FIELDS = frozenset({ @@ -108,6 +110,27 @@ def _bounded_number(value: object, *, minimum: float, maximum: float) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) and minimum <= value <= maximum +def _configured_capabilities(value: object, setting_name: str) -> tuple[Capability, ...]: + """Parse capabilities disabled by configuration without accepting ambiguous values.""" + if value is None: + return () + if isinstance(value, dict): + if not all(isinstance(name, str) and isinstance(enabled, bool) for name, enabled in value.items()): + raise PlanError(f"{setting_name} must map capability names to booleans") + names = [name for name, enabled in value.items() if enabled] + elif isinstance(value, (list, tuple)): + names = list(value) + else: + raise PlanError(f"{setting_name} must be a capability list or a capability-to-boolean mapping") + try: + capabilities = tuple(Capability(name) for name in names) + except (TypeError, ValueError) as error: + raise PlanError(f"{setting_name} contains an unsupported capability: {error}") from error + if len(set(capabilities)) != len(capabilities): + raise PlanError(f"{setting_name} must not contain duplicate capabilities") + return capabilities + + def _validate_collector_settings(settings: Mapping[str, Mapping[str, Any]]) -> None: """Validate collector identities and all documented fields before worker launch.""" for collector_id, values in settings.items(): @@ -267,7 +290,7 @@ def parse_mapping(index: int, indentation: int) -> tuple[dict[str, Any], int]: def default_configuration_yaml() -> str: """Return the YAML template written by installer and repair flows.""" - return """# Logicytics user configuration\nschema_version: 4\nruntime:\n output_root: output/data\n default_max_workers: 4\n maximum_workers: 16\n package_completed_runs: true\ninteraction:\n history_enabled: false\n similarity_threshold: 0.55\n model_name: stdlib-sequence-matcher\n model_debug: false\nmaintenance:\n local_manifest_path: project.manifest.json\n minimum_python: \"3.11\"\n recommended_python: \"3.11\"\n sysinternals_enabled: true\n sysinternals_download_url: https://download.sysinternals.com/files/SysinternalsSuite.zip\nlogging:\n level: INFO\n console_enabled: true\n color_enabled: true\n file_enabled: true\n maximum_bytes: 4194304\n delete_previous: false\n retention_days: 30\ncollectors: {}\n""" + return """# Logicytics user configuration\nschema_version: 4\nruntime:\n output_root: output/data\n default_max_workers: 4\n maximum_workers: 16\n package_completed_runs: true\n blocked_capabilities: {}\ninteraction:\n history_enabled: false\n similarity_threshold: 0.55\n model_name: stdlib-sequence-matcher\n model_debug: false\nmaintenance:\n local_manifest_path: project.manifest.json\n minimum_python: \"3.11\"\n recommended_python: \"3.11\"\n sysinternals_enabled: true\n sysinternals_download_url: https://download.sysinternals.com/files/SysinternalsSuite.zip\nlogging:\n level: INFO\n console_enabled: true\n color_enabled: true\n file_enabled: true\n maximum_bytes: 4194304\n delete_previous: false\n retention_days: 30\ncollectors: {}\n""" def write_default_configuration(project_root: Path, *, overwrite: bool = False) -> Path: @@ -290,6 +313,7 @@ class RuntimeSettings: maximum_workers: int = 16 package_completed_runs: bool = True maximum_run_output_bytes: int = DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES + blocked_capabilities: tuple[Capability, ...] = () @dataclass(frozen=True, slots=True) @@ -430,6 +454,10 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi maximum=MAXIMUM_RUN_OUTPUT_BYTES, ): raise PlanError("runtime maximum_run_output_bytes must be an integer from 1 to 68719476736") + blocked_capabilities = _configured_capabilities( + runtime_raw.get("blocked_capabilities", {}), + "runtime blocked_capabilities", + ) interaction_raw = raw.get("interaction", {}) if not isinstance(interaction_raw, dict): @@ -559,6 +587,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi maximum_workers=maximum_workers, package_completed_runs=package_completed_runs, maximum_run_output_bytes=maximum_run_output_bytes, + blocked_capabilities=blocked_capabilities, ), interaction=InteractionSettings( history_enabled=history_enabled, diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index 278af5d8..2042030c 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -526,6 +526,12 @@ def _validate_class_shape( f"{', '.join(missing_fields)}" ) + if candidate.kind is CollectorKind.CORE and len(metadata_calls) == 1: + if not any(keyword.arg == "capabilities" for keyword in metadata_calls[0].keywords): + candidate.static_errors.append( + "CAPABILITY_METADATA_MISSING: core metadata must explicitly declare capabilities" + ) + collect_method = methods.get("collect") if collect_method is not None and len(metadata_calls) == 1: diff --git a/logicytics/module/errors.py b/logicytics/module/errors.py index c8d2f00f..fa50a7cd 100644 --- a/logicytics/module/errors.py +++ b/logicytics/module/errors.py @@ -17,3 +17,19 @@ class PlanError(LogicyticsError): class ArtifactError(LogicyticsError): """Raised when an artifact violates workspace or output rules.""" + + +class CapabilityPolicyError(PermissionError): + """Raised when a collector's declared capability policy is violated.""" + + def __init__(self, code: str, capability: str, operation: str, detail: str = "") -> None: + """Create a stable, machine-searchable capability diagnostic.""" + self.code = code + self.capability = capability + self.operation = operation + action = "requested blocked" if code == "CAPABILITY_BLOCKED" else "used undeclared" + suffix = f"; {detail}" if detail else "" + super().__init__( + f"{code}: collector {action} {capability} capability; " + f"operation={operation}{suffix}" + ) diff --git a/logicytics/module/manifest.py b/logicytics/module/manifest.py index de79c7b1..1e0c26ad 100644 --- a/logicytics/module/manifest.py +++ b/logicytics/module/manifest.py @@ -89,7 +89,7 @@ def _failure_details(self) -> dict[str, str | bool]: retry_safe = False elif "permission" in diagnostic or "capability" in diagnostic or "access is denied" in diagnostic: operation = "access" - remediation = "Declare and explicitly approve the required capability or run with authorized privileges." + remediation = "Declare the capability in CollectorMetadata.capabilities and remove any active block before retrying." retry_safe = False elif "artifact" in diagnostic or "output limit" in diagnostic: operation = "artifact_registration" diff --git a/logicytics/module/mod_runner.py b/logicytics/module/mod_runner.py index b195641d..77a41a23 100644 --- a/logicytics/module/mod_runner.py +++ b/logicytics/module/mod_runner.py @@ -11,8 +11,11 @@ def main(argv: list[str] | None = None) -> int: """Run one copied Python MOD with workspace mutation and capability enforcement.""" arguments = sys.argv[1:] if argv is None else argv - if len(arguments) != 4: - print("Python MOD runner requires script, workspace, collector ID, and capabilities", file=sys.stderr) + if len(arguments) != 5: + print( + "Python MOD runner requires script, workspace, collector ID, capabilities, and blocked capabilities", + file=sys.stderr, + ) return 2 script = Path(arguments[0]).resolve() workspace = Path(arguments[1]).resolve() @@ -21,6 +24,9 @@ def main(argv: list[str] | None = None) -> int: capability_values = json.loads(arguments[3]) if not isinstance(capability_values, list): raise ValueError("capabilities must be a JSON list") + blocked_capability_values = json.loads(arguments[4]) + if not isinstance(blocked_capability_values, list): + raise ValueError("blocked capabilities must be a JSON list") except (json.JSONDecodeError, ValueError) as error: print(f"Invalid Python MOD capabilities: {error}", file=sys.stderr) return 2 @@ -32,6 +38,7 @@ def main(argv: list[str] | None = None) -> int: try: capabilities = tuple(Capability(value) for value in capability_values) + blocked_capabilities = tuple(Capability(value) for value in blocked_capability_values) except ValueError as error: print(f"Invalid Python MOD capability: {error}", file=sys.stderr) return 2 @@ -47,6 +54,7 @@ def main(argv: list[str] | None = None) -> int: collector_id, capabilities, script, + blocked_capabilities, ) guard.active = True previous_argv = sys.argv diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index e9f02f1e..e14ef83e 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -17,7 +17,7 @@ BUILTIN_PROFILES = MappingProxyType({ "minimal": "Essential local system, memory, and storage inventory only.", "standard": "Shipped core collectors explicitly declaring standard membership.", - "deep": "Extended declared inventory, subject to explicit capability approval.", + "deep": "Extended declared inventory, subject to declared-capability blocking policy.", "offline": "Declared local-only inventory with network and packet access prohibited.", }) _OFFLINE_PROHIBITED_CAPABILITIES = frozenset({Capability.NETWORK, Capability.PACKET_CAPTURE}) @@ -153,7 +153,7 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: selected[dependency_id] = dependency pending_dependencies.append(dependency_id) policy_errors: list[str] = [] - missing_capabilities_for_request: set[Capability] = set() + blocked_capabilities_for_request: set[Capability] = set() for candidate in sorted( selected.values(), key=lambda item: item.metadata.id if item.metadata else "", @@ -168,25 +168,25 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: ) if sys.platform not in candidate.metadata.supported_platforms: policy_errors.append(f"{candidate.metadata.id}: does not support {sys.platform}") - missing_capabilities = set(candidate.metadata.capabilities) - set(request.approved_capabilities) - if missing_capabilities: - missing_capabilities_for_request.update(missing_capabilities) - required = ", ".join(sorted(capability.value for capability in missing_capabilities)) + blocked_capabilities = set(candidate.metadata.capabilities).intersection(request.blocked_capabilities) + if blocked_capabilities: + blocked_capabilities_for_request.update(blocked_capabilities) + required = ", ".join(sorted(capability.value for capability in blocked_capabilities)) policy_errors.append( - f"{candidate.metadata.id}: requires unapproved capabilities: {required}" + f"{candidate.metadata.id}: capability blocked by policy: {required}" ) if policy_errors: details = "\n".join(f"- {error}" for error in policy_errors) hint = "" - if missing_capabilities_for_request: + if blocked_capabilities_for_request: flags = " ".join( - f"--allow-capability {capability.value}" + f"--block-capability {capability.value}" for capability in sorted( - missing_capabilities_for_request, + blocked_capabilities_for_request, key=lambda capability: capability.value, ) ) - hint = f"\nRerun with: {flags}" + hint = f"\nBlocked by: {flags}" raise PlanError(f"selected collector policy validation failed:\n{details}{hint}") elevated_collectors: list[str] = [] diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index 4ee4cb0e..256815e6 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -43,7 +43,7 @@ from logicytics.module.command_runner import parse_level_messages from logicytics.module.configuration import AppConfig from logicytics.module.discovery import CollectorCandidate -from logicytics.module.errors import LogicyticsError +from logicytics.module.errors import CapabilityPolicyError, LogicyticsError from logicytics.module.logging import FileEventLogger, get_application_logger, get_event_logger from logicytics.module.manifest import CollectorRecord, RunManifest, write_manifest, utc_now from logicytics.module.output_contracts import core_output_contract @@ -97,6 +97,7 @@ class _WorkerPayload(TypedDict): expected_class: str execution_type: str metadata: dict[str, object] + blocked_capabilities: tuple[Capability, ...] workspace: str artifact_root: str cancellation_file: str @@ -173,6 +174,7 @@ def __init__( collector_id: str, capabilities: tuple[Capability, ...], collector_source: Path, + blocked_capabilities: tuple[Capability, ...] = (), ) -> None: """Install audit boundaries for one collector's filesystem and capability scope.""" self.roots = (workspace.resolve(), (artifact_root / collector_id.replace(".", "_")).resolve()) @@ -187,9 +189,27 @@ def __init__( (source_tree.parent / "plugins").resolve(), ) self.capabilities = frozenset(capabilities) + self.blocked_capabilities = frozenset(blocked_capabilities) self.active = False sys.addaudithook(self._check_event) + def _require_capability(self, capability: Capability, operation: str, detail: str = "") -> None: + """Reject blocked access and undeclared access with distinct stable diagnostics.""" + if capability in self.blocked_capabilities: + raise CapabilityPolicyError( + "CAPABILITY_BLOCKED", + capability.value, + operation, + detail or "blocked by the active run policy", + ) + if capability not in self.capabilities: + raise CapabilityPolicyError( + "CAPABILITY_DECLARATION_MISMATCH", + capability.value, + operation, + detail or "not declared in CollectorMetadata.capabilities", + ) + def _check_path(self, value: object) -> None: """Reject writes that escape the collector workspace or artifact root.""" if isinstance(value, int): @@ -197,9 +217,16 @@ def _check_path(self, value: object) -> None: if not isinstance(value, (str, bytes, os.PathLike)): raise PermissionError("collector filesystem mutation has an unsupported target") path = Path(os.fsdecode(value)).resolve() - if Capability.FILESYSTEM_WRITE in self.capabilities: + if Capability.FILESYSTEM_WRITE in self.capabilities and ( + Capability.FILESYSTEM_WRITE not in self.blocked_capabilities + ): return if not any(path == root or root in path.parents for root in self.roots): + self._require_capability( + Capability.FILESYSTEM_WRITE, + "filesystem_write", + f"target={path}; collector filesystem mutation escapes its private workspace", + ) raise PermissionError(f"collector filesystem mutation escapes its private workspace: {path}") def _check_read(self, value: object) -> None: @@ -211,8 +238,11 @@ def _check_read(self, value: object) -> None: path == root or root in path.parents for root in (*self.roots, *self.runtime_roots) ): return - if Capability.FILESYSTEM_READ not in self.capabilities: - raise PermissionError("collector requires declared filesystem_read capability") + self._require_capability( + Capability.FILESYSTEM_READ, + "filesystem_read", + f"target={path}", + ) components = tuple(part.casefold() for part in path.parts) browser_data = any(part in {"chrome", "edge", "firefox", "opera software", "opera gx"} for part in components) private_key = ".ssh" in components or path.name.casefold() in { @@ -222,12 +252,12 @@ def _check_read(self, value: object) -> None: label in path.name.casefold() for label in ("cookie", "credential", "password", "token", "secret", "login data") ) - if browser_data and Capability.BROWSER_DATA not in self.capabilities: - raise PermissionError("collector requires declared browser_data capability") - if sensitive and Capability.SENSITIVE_FILES not in self.capabilities: - raise PermissionError("collector requires declared sensitive_files capability") - if private_key and Capability.PRIVATE_KEYS not in self.capabilities: - raise PermissionError("collector requires declared private_keys capability") + if browser_data: + self._require_capability(Capability.BROWSER_DATA, "browser_data", f"target={path}") + if sensitive: + self._require_capability(Capability.SENSITIVE_FILES, "sensitive_files", f"target={path}") + if private_key: + self._require_capability(Capability.PRIVATE_KEYS, "private_keys", f"target={path}") @staticmethod def _subprocess_tokens(arguments: tuple[object, ...]) -> tuple[str, ...]: @@ -321,25 +351,22 @@ def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: if not self.active: return if event == "subprocess.Popen": - if Capability.SUBPROCESS not in self.capabilities: - raise PermissionError("collector requires declared subprocess capability") + self._require_capability(Capability.SUBPROCESS, "subprocess") if prohibited := self._prohibited_subprocess(arguments): raise PermissionError(f"collector subprocess command is prohibited: {prohibited}") if event.startswith("socket.") and event in { "socket.__new__", "socket.bind", "socket.connect", "socket.sendto", "socket.getaddrinfo" }: - if Capability.NETWORK not in self.capabilities: - raise PermissionError("collector requires declared network capability") + self._require_capability(Capability.NETWORK, "network") socket_type = arguments[2] if len(arguments) > 2 else None if ( event == "socket.__new__" and isinstance(socket_type, int) and socket_type == int(socket.SOCK_RAW) ): - if Capability.PACKET_CAPTURE not in self.capabilities: - raise PermissionError("collector requires declared packet_capture capability") - if event.startswith("winreg.") and Capability.REGISTRY_READ not in self.capabilities: - raise PermissionError("collector requires declared registry_read capability") + self._require_capability(Capability.PACKET_CAPTURE, "packet_capture") + if event.startswith("winreg."): + self._require_capability(Capability.REGISTRY_READ, "registry_read") if event in self._BLOCKED_EVENTS: raise PermissionError(f"collector must not modify process state: {event}") if event == "open": @@ -395,6 +422,7 @@ def _mod_command( workspace: Path | None = None, collector_id: str = "mod.legacy", capabilities: tuple[Capability, ...] = (), + blocked_capabilities: tuple[Capability, ...] = (), ) -> list[str]: """Build a shell-free command for one copied legacy MODS script.""" if execution_type == "mod_python": @@ -409,6 +437,7 @@ def _mod_command( str(workspace), collector_id, json.dumps([capability.value for capability in capabilities]), + json.dumps([capability.value for capability in blocked_capabilities]), ] if execution_type == "mod_powershell": return ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)] @@ -448,7 +477,14 @@ def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage] ) stdout_path = workspace / "script_stdout.txt" stderr_path = workspace / "script_stderr.txt" - guard = _WorkerMutationGuard(workspace, artifact_root, metadata.id, metadata.capabilities, source) + guard = _WorkerMutationGuard( + workspace, + artifact_root, + metadata.id, + metadata.capabilities, + source, + payload["blocked_capabilities"], + ) result: CollectorResult try: if cancellation_file.exists(): @@ -460,6 +496,7 @@ def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage] workspace, metadata.id, metadata.capabilities, + payload["blocked_capabilities"], ) environment = { "PATH": os.environ.get("PATH", ""), @@ -516,6 +553,20 @@ def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage] errors=(f"exit code {completed.returncode}", completed.stderr.strip() or "no stderr"), artifacts=tuple(artifacts), ) + except CapabilityPolicyError as error: + guard.active = False + logger.event( + "error", + "capability_policy_violation", + code=error.code, + capability=error.capability, + operation=error.operation, + ) + result = CollectorResult.failed( + "legacy mod capability policy violation", + errors=(str(error),), + artifacts=writer.artifacts, + ) except BaseException as error: guard.active = False result = CollectorResult.failed( @@ -597,6 +648,7 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) metadata.id, metadata.capabilities, Path(payload["path"]), + payload["blocked_capabilities"], ) mutation_guard.active = True try: @@ -646,6 +698,16 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) raise TypeError("finalize() must return CollectorResult") if result.artifacts != writer.artifacts: raise TypeError("finalized result artifacts must exactly match registered artifacts") + except CapabilityPolicyError as error: + failure_summary = "collector capability policy violation" + context.logger.event( + "error", + "capability_policy_violation", + code=error.code, + capability=error.capability, + operation=error.operation, + ) + lifecycle_errors.extend((str(error), traceback.format_exc())) except BaseException as error: lifecycle_errors.extend((f"{type(error).__name__}: {error}", traceback.format_exc())) finally: @@ -720,6 +782,32 @@ def __init__(self, project_root: Path, configuration: AppConfig) -> None: def run(self, plan: RunPlan) -> RunOutcome: """Execute a preflighted plan and persist the manifest throughout the run.""" + configured_blocks = set(self.configuration.runtime.blocked_capabilities) + request_blocks = set(plan.request.blocked_capabilities) + blocked = configured_blocks.union(request_blocks) + policy_violations = { + metadata.id: sorted( + capability.value + for capability in set(metadata.capabilities).intersection(blocked) + ) + for metadata in (_require_metadata(candidate) for candidate in plan.collectors) + } + policy_violations = { + collector_id: capabilities + for collector_id, capabilities in policy_violations.items() + if capabilities + } + if policy_violations: + details = "; ".join( + f"{collector_id}={', '.join(capabilities)}" + for collector_id, capabilities in sorted(policy_violations.items()) + ) + raise CapabilityPolicyError( + "CAPABILITY_BLOCKED", + "multiple" if len(policy_violations) > 1 else next(iter(policy_violations.values()))[0], + "run", + f"collector policy prevents execution: {details}", + ) if plan.collectors and not plan.request.acknowledge_authorization: selected_metadata = tuple(_require_metadata(candidate) for candidate in plan.collectors) categories = sorted(str(metadata.specialty) for metadata in selected_metadata) @@ -1095,6 +1183,7 @@ def _supervise( "expected_class": candidate.expected_class, "execution_type": candidate.execution_type, "metadata": dict(cast(Mapping[str, object], metadata.to_dict())), + "blocked_capabilities": plan.request.blocked_capabilities, "workspace": str(workspace), "artifact_root": str(artifact_root), "cancellation_file": str(cancellation_file), @@ -1132,6 +1221,9 @@ def _supervise( "output_budget_bytes": output_budget, "resource_class": metadata.resource_class.value, "parallel_safe": metadata.parallel_safe, + "declared_capabilities": ",".join( + sorted(capability.value for capability in metadata.capabilities) + ) or "none", } run_logger.event("info", "collector_started", **start_fields) application_logger.event( @@ -1159,6 +1251,24 @@ def _supervise( artifact_bytes = self._collector_artifact_bytes(artifact_root, collector_id) committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) + capability_error = next( + ( + error + for error in record.errors + if error.startswith(("CAPABILITY_BLOCKED:", "CAPABILITY_DECLARATION_MISMATCH:")) + ), + None, + ) + if capability_error is not None: + application_logger.event( + "ERROR", + "capability_policy_violation", + source="logicytics.runtime", + run_id=run_id, + collector_id=collector_id, + code=capability_error.split(":", 1)[0], + detail=capability_error, + ) attempt_status = record.status attempt_duration = record.duration_seconds or 0.0 retry_scheduled = self._schedule_retry( diff --git a/tests/fixtures/collectors.py b/tests/fixtures/collectors.py index 1910e434..61bac4d1 100644 --- a/tests/fixtures/collectors.py +++ b/tests/fixtures/collectors.py @@ -32,6 +32,7 @@ def metadata(cls) -> CollectorMetadata: description="Creates a harmless text artifact for core tests.", author="tests", supported_platforms=("win32",), + capabilities=(), ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -55,7 +56,6 @@ def plugin_collector_source() -> str: return COLLECTOR.replace("CoreCollector", "PluginCollector").replace( ' author="tests",', ' author="tests",\n' - ' capabilities=(),\n' ' privilege_level=PrivilegeLevel.STANDARD,\n' ' sensitive_data_categories=(),\n' ' network_access=NetworkAccess.NONE,\n' diff --git a/tests/test_cli.py b/tests/test_cli.py index bce6760c..62cad1c6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -26,59 +26,48 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) - def test_standard_core_requests_approve_baseline_capabilities(self) -> None: - """The standard built-in CLI profile includes its required baseline access.""" + def test_capabilities_default_to_metadata_and_cli_can_block_them(self) -> None: + """Declared capabilities run by default and can be disabled per invocation.""" parser = cli_methods.parser() request = cli_methods.request( parser.parse_args(["run", "--profile", "standard"]), default_workers=4, ) - self.assertEqual( - (Capability.SUBPROCESS, Capability.NETWORK), - request.approved_capabilities, - ) + self.assertEqual((), request.blocked_capabilities) - with_extra_approval = cli_methods.request( + blocked = cli_methods.request( parser.parse_args([ "run", "--profile", "standard", - "--allow-capability", + "--block-capability", "network", - "--allow-capability", + "--block-capability", "filesystem_read", ]), default_workers=4, ) self.assertEqual( ( - Capability.SUBPROCESS, Capability.NETWORK, Capability.FILESYSTEM_READ, ), - with_extra_approval.approved_capabilities, + blocked.blocked_capabilities, ) - def test_baseline_capabilities_remain_explicit_for_extensions_and_direct_runs(self) -> None: - """Opt-in and direct execution keep the capability boundary fail-closed.""" + def test_configuration_capabilities_are_combined_with_cli_blocks(self) -> None: + """Configuration and invocation policy blocks are merged without duplicates.""" parser = cli_methods.parser() - restricted_requests = ( - ["run", "--profile", "standard", "--plugins"], - ["run", "--profile", "standard", "--mods"], - ["run", "--mode", "extensions"], - ["run", "--mode", "non-python"], - ["run", "--profile", "deep"], - ["collector", "core.network.network_adapters"], + request = cli_methods.request( + parser.parse_args(["run", "--block-capability", "subprocess"]), + default_workers=4, + configured_blocked_capabilities=(Capability.NETWORK, Capability.SUBPROCESS), + ) + self.assertEqual( + (Capability.NETWORK, Capability.SUBPROCESS), + request.blocked_capabilities, ) - - for arguments in restricted_requests: - with self.subTest(arguments=arguments): - request = cli_methods.request( - parser.parse_args(arguments), - default_workers=4, - ) - self.assertEqual((), request.approved_capabilities) def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 4286e756..468a9c47 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -16,6 +16,7 @@ ) from logicytics.module.discovery import preflight from logicytics.module.errors import PlanError +from logicytics.contracts import Capability from logicytics.module.planner import build_plan from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR @@ -24,6 +25,37 @@ class ConfigurationTests(unittest.TestCase): """Configuration parsing, migration, validation, and manifest behavior.""" + def test_runtime_blocked_capabilities_are_typed_and_default_empty(self) -> None: + """Capability blocks are explicit configuration policy rather than implicit approvals.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + self.assertEqual((), default_config(root).runtime.blocked_capabilities) + config_path = root / "logicytics.yaml" + config_path.write_text( + json.dumps({ + "schema_version": 4, + "runtime": { + "blocked_capabilities": { + "network": True, + "subprocess": False, + }, + }, + }), + encoding="utf-8", + ) + configuration = load_config(root) + self.assertEqual( + (Capability.NETWORK,), + configuration.runtime.blocked_capabilities, + ) + + config_path.write_text( + '{"schema_version":4,"runtime":{"blocked_capabilities":["invented"]}}', + encoding="utf-8", + ) + with self.assertRaisesRegex(PlanError, "unsupported capability"): + load_config(root) + def test_configuration_schema_version_is_enforced(self) -> None: """Only the current v4 schema is accepted by the authoritative YAML loader.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_mods.py b/tests/test_mods.py index f13d175e..a740ebc2 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -267,19 +267,12 @@ def test_native_mod_requires_explicit_filesystem_write_declaration( report.invalid, ) - with self.assertRaisesRegex( - PlanError, - "filesystem_write", - ): - build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=( - Capability.SUBPROCESS, - ), - ), - ) + plan = build_plan(report, RunRequest(enable_mods=True)) + self.assertEqual(("mod.native",), tuple( + item.metadata.id + for item in plan.collectors + if item.metadata is not None + )) def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers( self, diff --git a/tests/test_planning.py b/tests/test_planning.py index 27403ac6..8b538404 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -135,8 +135,7 @@ def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) - "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.SENSITIVE_FILES,),\n' ' sensitive_data_categories=("credentials",),\n' ' default_profiles=("deep",),', @@ -147,8 +146,14 @@ def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) - standard = build_plan(report, RunRequest()) self.assertEqual(["core.system.a_standard"], self._collector_ids(standard.collectors)) - with self.assertRaisesRegex(PlanError, "unapproved capabilities"): - build_plan(report, RunRequest(include=(sensitive_id,))) + with self.assertRaisesRegex(PlanError, "capability blocked by policy"): + build_plan( + report, + RunRequest( + include=(sensitive_id,), + blocked_capabilities=(Capability.SENSITIVE_FILES,), + ), + ) opted_in = build_plan( report, RunRequest( @@ -214,8 +219,7 @@ def test_offline_profile_rejects_network_collectors_even_with_explicit_approval( "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.NETWORK,),\n' ' network_access=NetworkAccess.LOCAL,\n' ' default_profiles=("standard",),', @@ -249,9 +253,8 @@ def test_policy_error_reports_every_selected_collector(self) -> None: "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - f" capabilities=({capability},),\n" + ' capabilities=(),', + f' capabilities=({capability},),\n' + ( " network_access=NetworkAccess.LOCAL,\n" if capability == "Capability.NETWORK" @@ -261,10 +264,15 @@ def test_policy_error_reports_every_selected_collector(self) -> None: (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") with self.assertRaisesRegex( - PlanError, - "selected collector policy validation failed", + PlanError, + "selected collector policy validation failed", ) as rejected: - build_plan(preflight(root), RunRequest()) + build_plan( + preflight(root), + RunRequest( + blocked_capabilities=(Capability.NETWORK, Capability.SUBPROCESS), + ), + ) message = str(rejected.exception) self.assertLess( @@ -282,7 +290,7 @@ def test_policy_error_reports_every_selected_collector(self) -> None: ): self.assertIn(collector_id, message) self.assertIn( - "Rerun with: --allow-capability network --allow-capability subprocess", + "Blocked by: --block-capability network --block-capability subprocess", message, ) @@ -297,8 +305,7 @@ def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) - "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.SENSITIVE_FILES,),\n' ' sensitive_data_categories=("credentials", "personal_documents"),\n' ' default_profiles=("deep",),', @@ -556,8 +563,7 @@ def test_planner_rejects_excluded_missing_and_sensitive_dependency_conflicts(sel "from logicytics import CollectorMetadata,", "from logicytics import Capability, CollectorMetadata,", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.SENSITIVE_FILES,),\n' ' sensitive_data_categories=("credentials",),\n' ' default_profiles=("deep",),', @@ -595,16 +601,14 @@ def test_capability_gate_requires_explicit_approval(self) -> None: "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - 'supported_platforms=("win32",),', - 'supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,),', + 'capabilities=(),', + 'capabilities=(Capability.FILESYSTEM_READ,),', ) collector_path.write_text(secured_collector, encoding="utf-8") report = preflight(root) - with self.assertRaises(PlanError): - build_plan(report, RunRequest()) plan = build_plan( report, - RunRequest(approved_capabilities=(Capability.FILESYSTEM_READ,)), + RunRequest(), ) self.assertEqual(1, len(plan.collectors)) @@ -618,15 +622,18 @@ def test_elevated_collectors_require_approval_and_administrator_privileges(self) "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - 'supported_platforms=("win32",),', - 'supported_platforms=("win32",), capabilities=(Capability.ELEVATED_PRIVILEGES,), ' + 'capabilities=(),', + 'capabilities=(Capability.ELEVATED_PRIVILEGES,), ' 'privilege_level=PrivilegeLevel.ELEVATED,', ) collector_path.write_text(secured_collector, encoding="utf-8") report = preflight(root) - with self.assertRaisesRegex(PlanError, "unapproved capabilities"): - build_plan(report, RunRequest()) - approved = RunRequest(approved_capabilities=(Capability.ELEVATED_PRIVILEGES,)) + with self.assertRaisesRegex(PlanError, "capability blocked by policy"): + build_plan( + report, + RunRequest(blocked_capabilities=(Capability.ELEVATED_PRIVILEGES,)), + ) + approved = RunRequest() for administrator_state in (False, None): with self.subTest(administrator_state=administrator_state): environment = EnvironmentReport(administrator_state, True, None) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 092e1752..66561082 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -50,6 +50,27 @@ def test_preflight_allows_global_ctypes_collector_infrastructure(self) -> None: self.assertEqual(1, len(report.valid), report.invalid) self.assertEqual((), report.invalid) + def test_core_preflight_requires_explicit_capability_metadata(self) -> None: + """A core script missing its capability declaration is rejected before execution.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace(" capabilities=(),\n", ""), + encoding="utf-8", + ) + + report = preflight(root) + + self.assertEqual((), report.valid) + self.assertEqual(1, len(report.invalid)) + self.assertIn( + "CAPABILITY_METADATA_MISSING", + report.invalid[0].static_errors[0], + ) + def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(self) -> None: """A plugin cannot silently inherit fields that affect consent or scheduling.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 55587c77..902ec02e 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -582,8 +582,7 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer "from pathlib import Path\n", "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.SUBPROCESS,),\n timeout_seconds=2,', ).replace( ' output = context.workspace / "system.txt"', diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index a1c2be6d..012841ca 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -201,8 +201,7 @@ def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabil "from pathlib import Path\n", "from pathlib import Path\nfrom logicytics import Capability\n", ).replace( - ' supported_platforms=("win32",),', - f' supported_platforms=("win32",),\n' + ' capabilities=(),', f' capabilities=({declared}),', ).replace( ' output = context.workspace / "system.txt"', @@ -216,7 +215,6 @@ def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabil RunRequest( max_workers=1, acknowledge_authorization=True, - approved_capabilities=capabilities, ), ) outcome = RunSupervisor(root, default_config(root)).run(plan) @@ -226,6 +224,10 @@ def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabil else: self.assertEqual("failed", record.status) self.assertIn(denied, "\n".join(record.errors)) + self.assertIn( + "CAPABILITY_DECLARATION_MISMATCH", + "\n".join(record.errors), + ) def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self) -> None: """General network approval must not silently authorize raw packet capture.""" @@ -239,8 +241,7 @@ def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self "from pathlib import Path\n", "from pathlib import Path\nimport socket\nfrom logicytics import Capability\n", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.NETWORK,),\n' ' network_access=NetworkAccess.LOCAL,', ).replace( @@ -262,8 +263,8 @@ def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self self.assertEqual("failed", outcome.manifest.collectors[0].status) self.assertIn("packet_capture capability", "\n".join(outcome.manifest.collectors[0].errors)) - def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> None: - """A declared subprocess capability remains usable after explicit request approval.""" + def test_worker_allows_an_explicitly_declared_subprocess_by_default(self) -> None: + """A declared subprocess capability is usable without a separate approval flag.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) collector_path = root / "core" / "system" / "system_info.py" @@ -274,8 +275,8 @@ def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> N "from pathlib import Path\n", "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n capabilities=(Capability.SUBPROCESS,),', + ' capabilities=(),', + ' capabilities=(Capability.SUBPROCESS,),', ).replace( ' output = context.workspace / "system.txt"', ' subprocess.run([sys.executable, "-c", "pass"], check=True)\n' @@ -285,11 +286,10 @@ def test_worker_allows_an_explicitly_declared_and_approved_subprocess(self) -> N ) plan = build_plan( preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - approved_capabilities=(Capability.SUBPROCESS,), - ), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), ) outcome = RunSupervisor(root, default_config(root)).run(plan) self.assertEqual("succeeded", outcome.manifest.collectors[0].status) @@ -326,8 +326,7 @@ def test_worker_blocks_application_update_power_and_peer_collector_commands_with "from pathlib import Path\n", "from pathlib import Path\nimport subprocess\nfrom logicytics import Capability\n", ).replace( - ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' + ' capabilities=(),', ' capabilities=(Capability.SUBPROCESS,),', ).replace( ' output = context.workspace / "system.txt"', From 74f1e07866dfd3cd617cd35b755b8820b01c3080 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 01:39:22 +0300 Subject: [PATCH 256/316] feat(cli): render console output through human logger Replace utility JSON dumps and raw status prints with readable, indented console sections while retaining structured diagnostic artifacts on disk. Humanize structured lifecycle fields, wrap long summaries, and normalize installer, test runner, MOD, and validation errors. Update CLI documentation and output-focused tests. --- README.md | 49 +++--- docs/V4_RELEASE.md | 5 +- logicytics/cli/commands.py | 215 +++++++++++++++---------- logicytics/cli/installer.py | 18 ++- logicytics/cli/tests.py | 10 +- logicytics/module/logging.py | 83 ++++++++-- logicytics/module/mod_runner.py | 34 +++- logicytics/module/validation_worker.py | 19 ++- tests/test_cli.py | 29 +++- tests/test_interaction.py | 34 ++-- tests/test_logging.py | 20 ++- tests/test_maintenance.py | 9 +- 12 files changed, 366 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index e9a4236f..ee4e071a 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,18 @@ equivalent: The CLI has three standalone utility actions and seven subcommands: -| Invocation | Purpose | -|---------------------------------------------|-------------------------------------------------------------------| -| python -m logicytics --usage | Show local interaction statistics and create flag_usage.svg. | -| python -m logicytics --modes | Print the machine-readable execution-mode matrix. | -| python -m logicytics --match TEXT | Suggest the closest documented action for natural-language input. | -| python -m logicytics preflight | Discover and validate collectors. | -| python -m logicytics debug | Write and print environment and preflight diagnostics. | -| python -m logicytics plan | Resolve a dependency-safe plan without collecting evidence. | -| python -m logicytics run | Execute the plan in isolated workers and publish a run. | -| python -m logicytics collector COLLECTOR_ID | Execute one exact collector independently. | -| python -m logicytics update | Check Git and optionally apply an update. | -| python -m logicytics dev | Run repository integrity checks and manage the local manifest. | +| Invocation | Purpose | +|---------------------------------------------|-----------------------------------------------------------------| +| python -m logicytics --usage | Show a formatted interaction summary and create flag_usage.svg. | +| python -m logicytics --modes | Show a formatted mode summary and save the JSON matrix to disk. | +| python -m logicytics --match TEXT | Show a formatted match result for natural-language input. | +| python -m logicytics preflight | Discover and validate collectors. | +| python -m logicytics debug | Show formatted diagnostics and write debug.json. | +| python -m logicytics plan | Resolve a dependency-safe plan without collecting evidence. | +| python -m logicytics run | Execute the plan in isolated workers and publish a run. | +| python -m logicytics collector COLLECTOR_ID | Execute one exact collector independently. | +| python -m logicytics update | Check Git and optionally apply an update. | +| python -m logicytics dev | Run repository integrity checks and manage the local manifest. | Standalone actions cannot be combined with a subcommand. Use --help after any action for the parser-generated help. @@ -78,13 +78,17 @@ action for the parser-generated help. These flags go before the action name: -| Flag | Description | -|---------------|----------------------------------------------------------------------| -| -h, --help | Show help and exit. | -| --config PATH | Load configuration from PATH instead of the root logicytics.yaml. | -| --usage | Show local interaction statistics and create the usage graph. | -| --modes | Print the complete execution-mode and collector matrix. | -| --match TEXT | Match free-form text to a documented action using the local matcher. | +| Flag | Description | +|---------------|---------------------------------------------------------------------------| +| -h, --help | Show help and exit. | +| --config PATH | Load configuration from PATH instead of the root logicytics.yaml. | +| --usage | Show local interaction statistics and create the usage graph. | +| --modes | Show the mode summary; save the complete matrix to logs/debug/modes.json. | +| --match TEXT | Show a formatted match result using the local matcher. | + +All normal command output uses the Logicytics console presentation: a severity +marker, a short message, and readable indented fields. Structured JSON is kept +in the documented artifact files and is never dumped directly to the console. ### Shared collector flags @@ -248,9 +252,10 @@ The rerun receives a new run ID and separate evidence directory. ### Debug -debug prints diagnostic JSON and writes output/data/debug_logs/debug.json. It -includes configuration, environment, Python/runtime details, Sysinternals -state, preflight counts, and maintenance checks. +debug shows a formatted diagnostic summary and writes the complete JSON report +to output/logs/debug/debug.json. The report includes configuration, +environment, Python/runtime details, Sysinternals state, preflight counts, and +maintenance checks. ```powershell python -m logicytics debug diff --git a/docs/V4_RELEASE.md b/docs/V4_RELEASE.md index 8e090851..59ca2198 100644 --- a/docs/V4_RELEASE.md +++ b/docs/V4_RELEASE.md @@ -47,8 +47,9 @@ collectors. The canonical profiles are `minimal`, `standard`, `deep`, and `offline`. The canonical modes are `standard`, `balanced`, `quick`, `thorough`, `offline`, `extensions`, `non-python`, and `performance`; all historical mode flags remain -validated aliases. The machine-readable `python -m logicytics --modes` output is -the authoritative collector inclusion matrix. +validated aliases. `python -m logicytics --modes` shows a human-readable mode +summary and writes the authoritative machine-readable collector inclusion +matrix to `output/logs/debug/modes.json`. Core evidence contracts include `application/json`, `application/octet-stream`, `application/xml`, `application/zip`, `text/csv`, `text/html`, `text/plain`, and diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index d4bedd9c..e3d54e78 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -19,7 +19,7 @@ from logicytics.module.environment import inspect_environment from logicytics.module.errors import LogicyticsError from logicytics.module.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph -from logicytics.module.logging import get_application_logger +from logicytics.module.logging import ApplicationLogger, get_application_logger from logicytics.module.maintenance import ( build_manifest, compare_files, @@ -505,18 +505,13 @@ def launch_action_window(root: Path, action: str) -> int: ) return process.pid - @staticmethod - def _status_marker(label: str, count: int) -> str: - """Return a colored interactive repository-status marker when a terminal supports it.""" - color = "\033[32m" if count == 0 else "\033[33m" - reset = "\033[0m" - return f"{color}{label}: {count}{reset}" if sys.stdout.isatty() else f"{label}: {count}" - def run_developer_action( self, root: Path, configuration: AppConfig, arguments: argparse.Namespace, + debug_logs: Path, + logger: object, ) -> int: """Run read-only contribution checks and an explicitly confirmed manifest update.""" settings = configuration.maintenance @@ -538,11 +533,6 @@ def run_developer_action( write_requested = arguments.write_manifest if arguments.interactive: - print("Contribution and repository organization checks") - - for name in ("missing", "modified", "extra", "unchanged"): - print(self._status_marker(name.title(), len(comparison[name]))) - organization_checks: tuple[ Literal[ "naming_violations", @@ -557,14 +547,15 @@ def run_developer_action( "missing_module_docstrings", "crowded_modules", ) - - for name in organization_checks: - print( - self._status_marker( - name.replace("_", " ").title(), - len(checks[name]), - ) - ) + logger.box( + "Contribution and repository organization checks", + ( + *(f"{name.title()}: {len(comparison[name])}" for name in ( + "missing", "modified", "extra", "unchanged", + )), + *(f"{name.replace('_', ' ').title()}: {len(checks[name])}" for name in organization_checks), + ), + ) if next_version is None: current_version = local_version(root) @@ -614,17 +605,30 @@ def run_developer_action( "next_version": next_version, } - print(json.dumps(payload, indent=2, sort_keys=True)) + development_path = debug_logs / "development.json" + self.write_json(development_path, payload) + logger.box( + "Development checks", + ( + f"Missing files: {len(comparison['missing'])}", + f"Modified files: {len(comparison['modified'])}", + f"Extra files: {len(comparison['extra'])}", + f"Unchanged files: {len(comparison['unchanged'])}", + f"Organization issues: {sum(len(checks[name]) for name in ('naming_violations', 'misplaced_python', 'missing_module_docstrings', 'crowded_modules'))}", + f"Manifest written: {'yes' if manifest_path else 'no'}", + f"Manifest path: {manifest_path or 'none'}", + f"Diagnostic report: {development_path}", + ), + ) return 0 def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" if sys.prefix == sys.base_prefix: - print( - "ERROR | Logicytics must run inside a virtual environment. " - "Run python -m logicytics.cli.installer first.", - file=sys.stderr, + sys.stderr.write( + " × Logicytics must run inside a virtual environment\n" + " Run python -m logicytics.cli.installer first.\n" ) return 2 cli_parser = cli_methods.parser() @@ -724,30 +728,24 @@ def finish_command( if configuration.interaction.history_enabled: record_match(history_path, match) - payload: dict[str, object] = { - "input": match.input, - "matched_flag": match.matched_flag, - "accuracy": match.accuracy, - "source": match.source, - "history_persisted": ( - configuration.interaction.history_enabled - ), - } - - if configuration.interaction.model_debug: - payload["model_debug"] = { - "model_name": match.model_name, - "threshold": ( - configuration.interaction.similarity_threshold + application_logger.box( + "Match result", + ( + f"Input: {match.input}", + f"Matched flag: {match.matched_flag or 'none'}", + f"Confidence: {match.accuracy:.1%}", + f"Match source: {match.source.replace('_', ' ')}", + f"History persisted: {'yes' if configuration.interaction.history_enabled else 'no'}", + *( + ( + "Model: " + f"{match.model_name} " + f"(threshold {configuration.interaction.similarity_threshold:.1%})", + ) + if configuration.interaction.model_debug + else () ), - } - - print( - json.dumps( - payload, - indent=2, - sort_keys=True, - ) + ), ) exit_code = 0 if match.matched_flag is not None else 1 @@ -768,17 +766,26 @@ def finish_command( statistics, ) - payload: dict[str, object] = { - **statistics, - "graph_path": str(graph_path), - } - - print( - json.dumps( - payload, - indent=2, - sort_keys=True, + frequencies = statistics.get("per_flag_frequency", {}) + frequency_rows = ( + tuple( + f"{flag}: {count}" + for flag, count in sorted(frequencies.items()) ) + if isinstance(frequencies, dict) and frequencies + else ("none",) + ) + application_logger.box( + "Interaction usage", + ( + f"Total interactions: {statistics['total_interactions']}", + f"Average confidence: {float(statistics['average_accuracy']):.1%}", + f"Common device: {statistics['common_device'] or 'none'}", + f"Common input: {statistics['common_input'] or 'none'}", + "Flag frequency:", + *(f" {row}" for row in frequency_rows), + f"Usage graph: {graph_path}", + ), ) return finish_command(0, status="usage_written") @@ -808,13 +815,23 @@ def finish_command( payload = mode_matrix( (*report.valid, *report.invalid) ) - - print( - json.dumps( - payload, - indent=2, - sort_keys=True, + matrix_path = layout.debug_logs / "modes.json" + cli_methods.write_json(matrix_path, payload) + mode_rows = [] + for item in payload["modes"]: + aliases = ", ".join(item["legacy_aliases"]) or "none" + mode_rows.append( + f"{item['name']}: {item['description']} " + f"({len(item['collector_ids'])} collectors; aliases: {aliases})" ) + application_logger.box( + "Execution modes", + ( + *mode_rows, + f"Valid collectors: {len(report.valid)}", + f"Invalid collectors: {len(report.invalid)}", + f"Machine-readable matrix: {matrix_path}", + ), ) return finish_command( @@ -907,12 +924,14 @@ def finish_command( payload, ) - print( - json.dumps( - payload, - indent=2, - sort_keys=True, - ) + application_logger.box( + "Diagnostics", + ( + f"Valid collectors: {len(report.valid)}", + f"Invalid collectors: {len(report.invalid)}", + f"Virtual environment: {'yes' if sys.prefix != sys.base_prefix else 'no'}", + f"Diagnostic report: {debug_path}", + ), ) exit_code = 0 if not report.invalid else 2 @@ -954,12 +973,16 @@ def finish_command( if arguments.apply: if git.returncode != 0 or not is_repository: - print( - json.dumps( - payload, - indent=2, - sort_keys=True, - ) + update_path = layout.debug_logs / "update.json" + cli_methods.write_json(update_path, payload) + application_logger.box( + "Update result", + ( + "Git available: no", + f"Repository: {'yes' if is_repository else 'no'}", + f"Diagnostic report: {update_path}", + "Update was not applied because Git or the repository is unavailable.", + ), ) return finish_command( 2, @@ -1005,13 +1028,31 @@ def finish_command( launched_process_id ) - print( - json.dumps( - payload, - indent=2, - sort_keys=True, + update_path = layout.debug_logs / "update.json" + cli_methods.write_json(update_path, payload) + update_lines = [ + f"Git available: {'yes' if git.returncode == 0 else 'no'}", + f"Repository: {'yes' if is_repository else 'no'}", + f"Update applied: {'yes' if arguments.apply else 'no'}", + f"Update status: {'succeeded' if update_succeeded else 'failed'}", + ] + if arguments.apply: + update_lines.append( + f"Git pull exit code: {pull_returncode if pull_returncode is not None else 'none'}" ) - ) + if payload.get("stdout"): + update_lines.extend(("Git output:", *str(payload["stdout"]).splitlines())) + if payload.get("stderr"): + update_lines.extend(("Git warnings:", *str(payload["stderr"]).splitlines())) + if payload.get("launched_action"): + update_lines.extend( + ( + f"Launched action: {payload['launched_action']}", + f"Launched process id: {payload['launched_process_id']}", + ) + ) + update_lines.append(f"Diagnostic report: {update_path}") + application_logger.box("Update result", tuple(update_lines)) return finish_command( 0 if update_succeeded else 1, @@ -1024,6 +1065,8 @@ def finish_command( root, configuration, arguments, + layout.debug_logs, + application_logger, ) return finish_command( exit_code, @@ -1191,7 +1234,11 @@ def finish_command( if application_logger is not None: application_logger.box("Command error", (str(error),)) else: - print(f"Error: {error}") + ApplicationLogger.render_section( + sys.stderr, + "Command error", + (str(error),), + ) return 2 diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 04fbce77..1dcab770 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -3,10 +3,12 @@ from __future__ import annotations import argparse +import sys import venv from pathlib import Path from logicytics.module.configuration import write_default_configuration +from logicytics.module.logging import ApplicationLogger def project_root() -> Path: @@ -22,14 +24,22 @@ def main(argv: list[str] | None = None) -> int: arguments = parser.parse_args(argv) root = project_root() environment = arguments.environment if arguments.environment.is_absolute() else root / arguments.environment + environment_status: str if not environment.exists(): venv.EnvBuilder(with_pip=True).create(environment) - print(f"Created virtual environment: {environment}") + environment_status = f"Created virtual environment: {environment}" else: - print(f"Using existing virtual environment: {environment}") + environment_status = f"Using existing virtual environment: {environment}" configuration = write_default_configuration(root, overwrite=arguments.overwrite_config) - print(f"Configuration ready: {configuration}") - print(f"Run: {environment / 'Scripts' / 'python.exe'} -m logicytics preflight") + ApplicationLogger.render_section( + sys.stdout, + "Logicytics installation", + ( + environment_status, + f"Configuration ready: {configuration}", + f"Next step: {environment / 'Scripts' / 'python.exe'} -m logicytics preflight", + ), + ) return 0 diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index 7c8e24e2..a405177e 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -10,7 +10,7 @@ from logicytics.module.configuration import load_config from logicytics.module.errors import PlanError -from logicytics.module.logging import get_application_logger +from logicytics.module.logging import ApplicationLogger, get_application_logger from logicytics.module.output_layout import ensure_output_layout @@ -33,14 +33,18 @@ def main(argv: list[str] | None = None) -> int: try: _require_virtual_environment() except RuntimeError as error: - print(f"ERROR | {error}", file=sys.stderr) + ApplicationLogger.render_section(sys.stderr, "Test runner error", (str(error),)) return 2 root = project_root() try: configuration = load_config(root) layout = ensure_output_layout(configuration.runtime.output_root) except (OSError, PlanError) as error: - print(f"ERROR | unable to prepare test presentation: {error}", file=sys.stderr) + ApplicationLogger.render_section( + sys.stderr, + "Test runner error", + (f"Unable to prepare test presentation: {error}",), + ) return 2 logger = get_application_logger(layout.application_log, configuration.logging) logger.event("INFO", "test_suite_started", source="logicytics.cli.tests") diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index b6b65a9b..a0931ca2 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -12,7 +12,7 @@ from pathlib import Path from threading import RLock from time import perf_counter, time -from typing import Callable, Iterable, ParamSpec, TextIO, TypeVar +from typing import Callable, Iterable, Mapping, ParamSpec, TextIO, TypeVar from logicytics.contracts import EventLogger from logicytics.module.configuration import LoggingSettings @@ -172,6 +172,39 @@ def _console_message(message: str) -> str: return message.replace("_", " ").capitalize() return message + @classmethod + def _console_fields(cls, fields: Mapping[str, object]) -> tuple[str, ...]: + """Render structured fields as readable labels instead of JSON fragments.""" + rows: list[str] = [] + for key, value in sorted(fields.items()): + label = key.replace("_", " ").capitalize() + rendered = cls._console_value(value).splitlines() or ["none"] + rows.append(f"{label}: {rendered[0]}") + rows.extend(f" {line}" for line in rendered[1:]) + return tuple(rows) + + @classmethod + def _console_value(cls, value: object) -> str: + """Normalize common structured values for compact, human-readable console output.""" + if value is None: + return "none" + if isinstance(value, bool): + return "yes" if value else "no" + if isinstance(value, Mapping): + if not value: + return "none" + return "; ".join( + f"{str(key).replace('_', ' ')}: {cls._console_value(item)}" + for key, item in sorted(value.items(), key=lambda item: str(item[0])) + ) + if isinstance(value, (list, tuple, set, frozenset)): + if not value: + return "none" + return ", ".join(cls._console_value(item) for item in value) + if isinstance(value, float): + return f"{value:.6f}".rstrip("0").rstrip(".") + return str(value) + def event(self, level: str, message: str, **fields: int | float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" normalized = level.upper() @@ -187,7 +220,9 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: for key, value in sorted(safe_fields.items()) ) rendered_message = safe_message + suffix - console_message = self._console_message(safe_message) + suffix + console_lines = [self._console_message(safe_message)] + console_lines.extend(self._console_fields(safe_fields)) + console_message = "\n".join(console_lines) rows = self._rows(normalized, source, rendered_message) with self._lock: if self.settings.file_enabled: @@ -228,18 +263,39 @@ def separator(self) -> None: """Write one presentation-only blank line to the configured console.""" self.raw("") - def box(self, title: str, lines: Iterable[str]) -> None: - """Render console-only output as plain redacted lines.""" + @classmethod + def render_section( + cls, + console: TextIO, + title: str, + lines: Iterable[str], + ) -> None: + """Render a plain, indented console section for startup and fallback paths.""" rows = [redact_text(title)] for line in lines: - rows.extend( - f" {redact_text(row)}" - for row in line.splitlines() or [""] - ) + for raw_row in line.splitlines() or [""]: + safe_row = redact_text(raw_row) + indentation = safe_row[:len(safe_row) - len(safe_row.lstrip())] + remaining = safe_row.lstrip().rstrip() + prefix = f" {indentation}" + continuation_prefix = f" {indentation}" + available = max(cls._console_width() - len(continuation_prefix), 1) + wrapped = textwrap.wrap( + remaining, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + rows.append(prefix + wrapped[0]) + rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) + console.write("\n".join(rows) + "\n") + console.flush() + + def box(self, title: str, lines: Iterable[str]) -> None: + """Render console-only output as plain redacted lines.""" with self._lock: if self.settings.console_enabled: - self.console.write("\n".join(rows) + "\n") - self.console.flush() + self.render_section(self.console, title, lines) def dispatch(self, messages: Iterable[str]) -> None: """Parse and dispatch a batch of optional `LEVEL: message` rows.""" @@ -262,7 +318,12 @@ def get_application_logger( resolved = path.resolve() with _LOGGER_LOCK: logger = _APPLICATION_LOGGERS.get(resolved) - if logger is None or logger.settings != settings or console is not None: + if ( + logger is None + or logger.settings != settings + or console is not None + or (console is None and logger.console is not sys.stderr) + ): logger = ApplicationLogger(resolved, settings, console=console) if console is None: _APPLICATION_LOGGERS[resolved] = logger diff --git a/logicytics/module/mod_runner.py b/logicytics/module/mod_runner.py index 77a41a23..506818c4 100644 --- a/logicytics/module/mod_runner.py +++ b/logicytics/module/mod_runner.py @@ -11,10 +11,18 @@ def main(argv: list[str] | None = None) -> int: """Run one copied Python MOD with workspace mutation and capability enforcement.""" arguments = sys.argv[1:] if argv is None else argv + engine_root = Path(__file__).resolve().parents[2] + sys.path.insert(0, str(engine_root)) + from logicytics.module.logging import ApplicationLogger + if len(arguments) != 5: - print( - "Python MOD runner requires script, workspace, collector ID, capabilities, and blocked capabilities", - file=sys.stderr, + ApplicationLogger.render_section( + sys.stderr, + "Python MOD runner error", + ( + "The runner requires script, workspace, collector ID, " + "capabilities, and blocked capabilities.", + ), ) return 2 script = Path(arguments[0]).resolve() @@ -28,11 +36,13 @@ def main(argv: list[str] | None = None) -> int: if not isinstance(blocked_capability_values, list): raise ValueError("blocked capabilities must be a JSON list") except (json.JSONDecodeError, ValueError) as error: - print(f"Invalid Python MOD capabilities: {error}", file=sys.stderr) + ApplicationLogger.render_section( + sys.stderr, + "Python MOD runner error", + (f"Invalid capabilities: {error}",), + ) return 2 - engine_root = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(engine_root)) from logicytics.contracts import Capability from logicytics.module.runtime import _WorkerMutationGuard @@ -40,12 +50,20 @@ def main(argv: list[str] | None = None) -> int: capabilities = tuple(Capability(value) for value in capability_values) blocked_capabilities = tuple(Capability(value) for value in blocked_capability_values) except ValueError as error: - print(f"Invalid Python MOD capability: {error}", file=sys.stderr) + ApplicationLogger.render_section( + sys.stderr, + "Python MOD runner error", + (f"Invalid capability: {error}",), + ) return 2 try: script.relative_to(workspace) except ValueError: - print("Python MOD script must be copied inside its private workspace", file=sys.stderr) + ApplicationLogger.render_section( + sys.stderr, + "Python MOD runner error", + ("The script must be copied inside its private workspace.",), + ) return 2 guard = _WorkerMutationGuard( diff --git a/logicytics/module/validation_worker.py b/logicytics/module/validation_worker.py index 983d65e6..cd707ad8 100644 --- a/logicytics/module/validation_worker.py +++ b/logicytics/module/validation_worker.py @@ -21,6 +21,7 @@ PluginCollector, ValidationResult, EventLogger, Artifact, EvidenceKind, ArtifactWriter, ) +from logicytics.module.logging import ApplicationLogger class _ProbeLogger(EventLogger): @@ -230,7 +231,7 @@ def _validate_contract( def main() -> int: - """Validate a collector module and emit only JSON to stdout.""" + """Validate a collector module and keep JSON stdout private to the parent worker.""" try: path = Path(sys.argv[1]).resolve() kind = CollectorKind(sys.argv[2]) @@ -248,10 +249,22 @@ def main() -> int: raise ValueError(f"{expected_class} is not a Collector") metadata = _validate_contract(collector_type, kind) - print(json.dumps({"metadata": metadata.to_dict()}, sort_keys=True)) + protocol_message = json.dumps({"metadata": metadata.to_dict()}, sort_keys=True) + if sys.stdout.isatty(): + ApplicationLogger.render_section( + sys.stdout, + "Collector validation", + ("Metadata validated successfully.",), + ) + else: + sys.stdout.write(protocol_message + "\n") return 0 except (IndexError, TypeError, ValueError, ImportError, OSError) as error: - print(str(error), file=sys.stderr) + ApplicationLogger.render_section( + sys.stderr, + "Collector validation error", + (str(error),), + ) return 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 62cad1c6..ddac1903 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -148,8 +148,8 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None with patch("sys.stderr"), self.assertRaises(SystemExit): parser.parse_args(["run", "--mode", "quick", "--minimal"]) - def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: - """Users and release checks can inspect the same authoritative mode registry.""" + def test_modes_action_renders_a_summary_and_writes_the_machine_readable_matrix(self) -> None: + """Users see a readable summary while release checks use the saved mode registry.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) output = io.StringIO() @@ -159,12 +159,20 @@ def test_modes_action_prints_the_complete_machine_readable_matrix(self) -> None: "project_root", return_value=root, ), patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual(0, main(["--modes"])) - payload = json.loads(output.getvalue()) + rendered = output.getvalue() + self.assertIn("Execution modes", rendered) + self.assertIn("Machine-readable matrix:", rendered) + self.assertNotIn("{", rendered) + payload = json.loads( + (root / "output" / "logs" / "debug" / "modes.json").read_text( + encoding="utf-8" + ) + ) self.assertEqual(1, payload["schema_version"]) self.assertEqual( @@ -219,7 +227,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( CLI.launch_action_window.__name__, return_value=321, ) as launch, patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual( @@ -232,7 +240,16 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( ]), ) - payload = json.loads(output.getvalue()) + rendered = output.getvalue() + self.assertIn("Update result", rendered) + self.assertIn("Launched action: debug", rendered) + self.assertIn("Launched process id: 321", rendered) + self.assertNotIn("{", rendered) + payload = json.loads( + (root / "output" / "logs" / "debug" / "update.json").read_text( + encoding="utf-8" + ) + ) self.assertEqual("debug", payload["launched_action"]) self.assertEqual(321, payload["launched_process_id"]) launch.assert_called_once_with(root, "debug") diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 8ed3550f..27b7c982 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -24,13 +24,16 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel root = Path(temporary) output = io.StringIO() with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual(0, main(["--match", "run a quick basic collection"])) - payload = json.loads(output.getvalue()) - self.assertEqual("minimal", payload["matched_flag"]) - self.assertFalse(payload["history_persisted"]) + rendered = output.getvalue() + self.assertIn("Match result", rendered) + self.assertIn("Matched flag: minimal", rendered) + self.assertIn("History persisted: no", rendered) + self.assertNotIn("{", rendered) + self.assertNotIn('"matched_flag"', rendered) self.assertFalse((root / "output" / "data" / "interaction_history.json.gz").exists()) (root / "logicytics.yaml").write_text( @@ -47,13 +50,16 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel ) output = io.StringIO() with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual(0, main(["--match", "an exhaustive slow scan"])) - payload = json.loads(output.getvalue()) - self.assertEqual("depth", payload["matched_flag"]) - self.assertEqual("stdlib-test-model", payload["model_debug"]["model_name"]) + rendered = output.getvalue() + self.assertIn("Match result", rendered) + self.assertIn("Matched flag: depth", rendered) + self.assertIn("Model: stdlib-test-model", rendered) + self.assertIn("History persisted: yes", rendered) + self.assertNotIn("{", rendered) history_path = root / "output" / "data" / "interaction_history.json.gz" history = load_history(history_path) self.assertEqual(1, len(history)) @@ -62,14 +68,16 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel output = io.StringIO() with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual(0, main(["--usage"])) - usage = json.loads(output.getvalue()) - self.assertEqual(1, usage["total_interactions"]) - self.assertEqual(1, usage["per_flag_frequency"]["depth"]) - graph_path = Path(usage["graph_path"]) + rendered = output.getvalue() + self.assertIn("Interaction usage", rendered) + self.assertIn("Total interactions: 1", rendered) + self.assertIn("depth: 1", rendered) + self.assertNotIn("{", rendered) + graph_path = root / "output" / "data" / "flag_usage.svg" self.assertTrue(graph_path.exists()) self.assertIn(" bool: self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) + def test_application_logging_wraps_long_plain_sections(self) -> None: + """Human summaries keep long diagnostics readable within the console width.""" + with tempfile.TemporaryDirectory() as temporary: + console = io.StringIO() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(file_enabled=False, color_enabled=False), + console=console, + ) + logger.box("Summary", ("detail " * 40,)) + + rows = console.getvalue().splitlines() + self.assertEqual("Summary", rows[0]) + self.assertGreater(len(rows), 2) + self.assertTrue(all(len(row) <= ApplicationLogger._console_width() for row in rows)) + def test_application_logging_colors_info_text_white_and_debug_text_gray(self) -> None: """Status markers retain their severity color while message text stays readable.""" @@ -213,7 +229,9 @@ def isatty(self) -> bool: "\033[90m\033[1m \u00b7 \033[0m\033[90m\033[1mdebug message", output, ) - self.assertIn("Collector finished collector_id=", output) + self.assertIn("Collector finished\n", output) + self.assertIn("Collector id: core.system.system_info", output) + self.assertNotIn("collector_id=", output) def test_deprecation_decorator_logs_removal_context(self) -> None: """Deprecated functions must preserve behavior while reporting removal context.""" diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index e6a54c0d..ab49c66f 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -230,14 +230,19 @@ def test_dev_writes_manifest_without_mutating_legacy_ini(self) -> None: ) output = io.StringIO() with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", + "sys.stderr", output, ): self.assertEqual( 0, main(["dev", "--write-manifest", "--next-version", "4.1.0"]), ) - payload = json.loads(output.getvalue()) + rendered = output.getvalue() + self.assertIn("Development checks", rendered) + self.assertIn("Manifest written: yes", rendered) + self.assertNotIn("{", rendered) + development_path = root / "output" / "logs" / "debug" / "development.json" + payload = json.loads(development_path.read_text(encoding="utf-8")) self.assertEqual( (root / "project.manifest.json").resolve(), Path(payload["manifest_written"]).resolve(), From 7290c0f2d86f5d44a206ecfaad2af4b5c2e17984 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 01:51:12 +0300 Subject: [PATCH 257/316] feat(logging): harden human-readable event presentation Normalize severity handling across application and JSONL sinks, humanize structured messages and values, and add millisecond timestamps to bounded application logs. Route startup and argparse failures through the shared line-based renderer, extend structured redaction to JSON text, and document the console and log contracts with regression coverage. --- README.md | 6 ++ docs/CONFIGURATION.md | 2 +- docs/OUTPUTS.md | 10 +++ logicytics/cli/commands.py | 16 +++-- logicytics/cli/installer.py | 4 +- logicytics/cli/tests.py | 4 +- logicytics/module/logging.py | 118 ++++++++++++++++++++++++++------- logicytics/module/redaction.py | 13 +++- tests/test_logging.py | 41 +++++++++++- 9 files changed, 177 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index ee4e071a..9313c8e1 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ These flags go before the action name: All normal command output uses the Logicytics console presentation: a severity marker, a short message, and readable indented fields. Structured JSON is kept in the documented artifact files and is never dumped directly to the console. +INFO text is white, DEBUG text is gray, warnings are yellow, and failures are +red. Long messages wrap to the terminal width; application log fields are +written as readable continuation lines with millisecond timestamps. Argument +errors, startup failures, installer output, and test-runner output use the same +line-based presentation. Run and collector JSONL files remain private, +redacted machine-readable artifacts for tooling. ### Shared collector flags diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9de0bacd..ca8bec3c 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,7 +17,7 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. `runtime.blocked_capabilities` is a mapping of capability names to booleans; true values disable matching metadata requests for the run. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. -Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping; machine-oriented lifecycle names such as `collector_finished` are shown as readable text such as `Collector finished`. File rows keep the stable aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and align continuation text beneath the message column. Command lifecycle events include terminal status and elapsed time, while run and collector events add worker, retry, artifact, and resource context. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. +Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping; machine-oriented lifecycle names such as `collector_finished` are shown as readable text such as `Collector finished`. INFO text is white, DEBUG text is gray, and the severity marker remains color-coded. File rows include millisecond timestamps, keep the stable aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and render fields as readable continuation lines beneath the message column. JSON-looking messages, mappings, sets, and binary values are normalized before human presentation. Command lifecycle events include terminal status and elapsed time, while run and collector events add worker, retry, artifact, and resource context. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. The run and collector JSONL files remain intentionally machine-readable and are the only JSON logging sink. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. ## Collector settings diff --git a/docs/OUTPUTS.md b/docs/OUTPUTS.md index 3e254224..4e4df2f0 100644 --- a/docs/OUTPUTS.md +++ b/docs/OUTPUTS.md @@ -57,3 +57,13 @@ the same retained-with-run rule but has no ZIP or package-hash sidecar. Global application, debug, and performance logs live under `output/logs/` and follow the logging limits described in the configuration documentation; they are not collector outputs and never enter the evidence catalog. + +## Logging presentation + +The application log is a bounded, human-readable text log. Each row contains a +local timestamp with milliseconds, severity, source, and message; long messages +and fields continue on aligned rows. Console output uses the same redaction and +humanization rules, with colored severity markers and readable indented fields. +It never emits raw JSON or `key=value` field fragments. Structured run and +collector event logs are stored separately as JSONL for tooling and remain +redacted; they are not printed to the console. diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index e3d54e78..32eed58d 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -19,7 +19,7 @@ from logicytics.module.environment import inspect_environment from logicytics.module.errors import LogicyticsError from logicytics.module.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph -from logicytics.module.logging import ApplicationLogger, get_application_logger +from logicytics.module.logging import ApplicationLogger, HumanArgumentParser, get_application_logger from logicytics.module.maintenance import ( build_manifest, compare_files, @@ -307,7 +307,7 @@ def request( @staticmethod def parser() -> argparse.ArgumentParser: """Create the complete parser for maintenance, planning, and collection commands.""" - parser = argparse.ArgumentParser(description="Logicytics v4 run-oriented evidence framework") + parser = HumanArgumentParser(description="Logicytics v4 run-oriented evidence framework") parser.add_argument( "--config", type=Path, @@ -318,7 +318,7 @@ def parser() -> argparse.ArgumentParser: parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") parser.add_argument("--match", metavar="TEXT", help="Suggest the closest documented action for natural-language input.") - subcommands = parser.add_subparsers(dest="command") + subcommands = parser.add_subparsers(dest="command", parser_class=HumanArgumentParser) for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") subparser.add_argument( @@ -626,9 +626,13 @@ def run_developer_action( def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" if sys.prefix == sys.base_prefix: - sys.stderr.write( - " × Logicytics must run inside a virtual environment\n" - " Run python -m logicytics.cli.installer first.\n" + ApplicationLogger.render_section( + sys.stderr, + "Logicytics startup error", + ( + "Logicytics must run inside a virtual environment.", + "Next step: run python -m logicytics.cli.installer first.", + ), ) return 2 cli_parser = cli_methods.parser() diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 1dcab770..30fa569b 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -8,7 +8,7 @@ from pathlib import Path from logicytics.module.configuration import write_default_configuration -from logicytics.module.logging import ApplicationLogger +from logicytics.module.logging import ApplicationLogger, HumanArgumentParser def project_root() -> Path: @@ -18,7 +18,7 @@ def project_root() -> Path: def main(argv: list[str] | None = None) -> int: """Create the local virtual environment and initialize YAML configuration.""" - parser = argparse.ArgumentParser(description="Prepare a Logicytics installation.") + parser = HumanArgumentParser(description="Prepare a Logicytics installation.") parser.add_argument("--environment", type=Path, default=Path(".venv"), help="Virtual environment directory.") parser.add_argument("--overwrite-config", action="store_true", help="Replace the root YAML template.") arguments = parser.parse_args(argv) diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index a405177e..9fc04f98 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -10,7 +10,7 @@ from logicytics.module.configuration import load_config from logicytics.module.errors import PlanError -from logicytics.module.logging import ApplicationLogger, get_application_logger +from logicytics.module.logging import ApplicationLogger, HumanArgumentParser, get_application_logger from logicytics.module.output_layout import ensure_output_layout @@ -27,7 +27,7 @@ def _require_virtual_environment() -> None: def main(argv: list[str] | None = None) -> int: """Dynamically discover tests and return a CI-appropriate result code.""" - parser = argparse.ArgumentParser(description="Run all discovered Logicytics tests.") + parser = HumanArgumentParser(description="Run all discovered Logicytics tests.") parser.add_argument("--verbosity", type=int, choices=(0, 1, 2), default=2) arguments = parser.parse_args(argv) try: diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index a0931ca2..f3383338 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -2,7 +2,9 @@ from __future__ import annotations +import argparse import json +import math import re import shutil import sys @@ -41,19 +43,29 @@ _RESET = "\033[0m" _BOLD = "\033[1m" _FILE_LOG_LINE_WIDTH = 140 -_TIME_WIDTH = 19 +_TIME_WIDTH = 23 _SEVERITY_WIDTH = 9 _SOURCE_WIDTH = 28 _DEFAULT_CONSOLE_WIDTH = 82 _MIN_CONSOLE_WIDTH = 60 _RIGHT_EDGE_MARGIN = 4 -_RECORD_START = re.compile(rb"(?m)^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") +_RECORD_START = re.compile(rb"(?m)^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})? \|") _EVENT_NAME = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)+$") _LOGGER_LOCK = RLock() _APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} _EVENT_LOGGERS: dict[tuple[Path, str, str | None], "FileEventLogger"] = {} +def _normalize_level(level: str) -> str: + """Normalize one severity spelling and reject values no sink can interpret.""" + if not isinstance(level, str): + raise ValueError(f"unsupported log level: {level!r}") + normalized = level.strip().upper() + if normalized not in _LEVEL_ORDER: + raise ValueError(f"unsupported log level: {level}") + return normalized + + class ApplicationLogger(EventLogger): """Thread-safe human-readable file and colored-console event sink.""" @@ -100,7 +112,7 @@ def _truncate_file(self) -> None: @staticmethod def _rows(level: str, source: str, message: str) -> tuple[str, ...]: """Format AIBrain-style fixed columns and aligned wrapped rows.""" - timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") + timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] source_column = source.removeprefix("logicytics.") if len(source_column) > _SOURCE_WIDTH: source_column = source_column[:_SOURCE_WIDTH - 3] + "..." @@ -172,6 +184,29 @@ def _console_message(message: str) -> str: return message.replace("_", " ").capitalize() return message + @classmethod + def _message_lines(cls, message: str) -> tuple[str, ...]: + """Render ordinary or JSON-looking messages as readable presentation lines.""" + safe_message = redact_text(message) + stripped = safe_message.strip() + if stripped.startswith(("{", "[")) and stripped.endswith(("}", "]")): + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, Mapping): + rows = ["Structured details"] + rows.extend(cls._console_fields(parsed)) + return tuple(rows) + if isinstance(parsed, list): + rows = ["Structured details"] + rows.extend( + f"Item {index}: {cls._console_value(item)}" + for index, item in enumerate(parsed, start=1) + ) + return tuple(rows) + return (cls._console_message(safe_message),) + @classmethod def _console_fields(cls, fields: Mapping[str, object]) -> tuple[str, ...]: """Render structured fields as readable labels instead of JSON fragments.""" @@ -197,30 +232,45 @@ def _console_value(cls, value: object) -> str: f"{str(key).replace('_', ' ')}: {cls._console_value(item)}" for key, item in sorted(value.items(), key=lambda item: str(item[0])) ) - if isinstance(value, (list, tuple, set, frozenset)): + if isinstance(value, (set, frozenset)): + if not value: + return "none" + rendered_items = sorted(cls._console_value(item) for item in value) + return ", ".join(rendered_items) + if isinstance(value, (list, tuple)): if not value: return "none" return ", ".join(cls._console_value(item) for item in value) + if isinstance(value, (bytes, bytearray, memoryview)): + return f"<{len(value)} bytes>" if isinstance(value, float): + if not math.isfinite(value): + return "non-finite float" return f"{value:.6f}".rstrip("0").rstrip(".") + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith(("{", "[")) and stripped.endswith(("}", "]")): + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if parsed is not None: + return cls._console_value(parsed) return str(value) def event(self, level: str, message: str, **fields: int | float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" - normalized = level.upper() - if normalized not in _LEVEL_ORDER: - raise ValueError(f"unsupported log level: {level}") - if _LEVEL_ORDER[normalized] < _LEVEL_ORDER[self.settings.level]: + normalized = _normalize_level(level) + minimum_level = _normalize_level(self.settings.level) + if _LEVEL_ORDER[normalized] < _LEVEL_ORDER[minimum_level]: return safe_message = redact_text(message) safe_fields = redact_mapping(fields) source = str(safe_fields.pop("source", "logicytics.module")) - suffix = "" if not safe_fields else " " + " ".join( - f"{key}={json.dumps(value, ensure_ascii=True, sort_keys=True)}" - for key, value in sorted(safe_fields.items()) - ) - rendered_message = safe_message + suffix - console_lines = [self._console_message(safe_message)] + file_lines = list(self._message_lines(safe_message)) + file_lines.extend(self._console_fields(safe_fields)) + rendered_message = "\n".join(file_lines) + console_lines = list(self._message_lines(safe_message)) console_lines.extend(self._console_fields(safe_fields)) console_message = "\n".join(console_lines) rows = self._rows(normalized, source, rendered_message) @@ -271,7 +321,7 @@ def render_section( lines: Iterable[str], ) -> None: """Render a plain, indented console section for startup and fallback paths.""" - rows = [redact_text(title)] + rows = list(cls._message_lines(title)) for line in lines: for raw_row in line.splitlines() or [""]: safe_row = redact_text(raw_row) @@ -280,14 +330,17 @@ def render_section( prefix = f" {indentation}" continuation_prefix = f" {indentation}" available = max(cls._console_width() - len(continuation_prefix), 1) - wrapped = textwrap.wrap( - remaining, - width=available, - break_long_words=True, - break_on_hyphens=False, - ) or [""] - rows.append(prefix + wrapped[0]) - rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) + presentation_rows = cls._message_lines(remaining) + for presentation_index, presentation_row in enumerate(presentation_rows): + wrapped = textwrap.wrap( + presentation_row, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + row_prefix = prefix if presentation_index == 0 else continuation_prefix + rows.append(row_prefix + wrapped[0]) + rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) console.write("\n".join(rows) + "\n") console.flush() @@ -308,6 +361,22 @@ def dispatch(self, messages: Iterable[str]) -> None: self.event("INFO", message.strip()) +class HumanArgumentParser(argparse.ArgumentParser): + """Present argparse failures through the same readable CLI section layout.""" + + def error(self, message: str) -> None: + """Render one concise argument error and its usage without raw argparse output.""" + usage = self.format_usage().strip() + if usage.lower().startswith("usage:"): + usage = usage[len("usage:"):].strip() + ApplicationLogger.render_section( + sys.stderr, + "Command-line error", + (f"Error: {message}", f"Usage: {usage}"), + ) + raise SystemExit(2) + + def get_application_logger( path: Path, settings: LoggingSettings, @@ -343,9 +412,10 @@ def __init__(self, path: Path, *, run_id: str, collector_id: str | None = None) def event(self, level: str, message: str, **fields: int | float | str) -> None: """Write a timestamped, structured event without relying on global handlers.""" + normalized = _normalize_level(level) payload: dict[str, object] = { "at": datetime.now(timezone.utc).isoformat(), - "level": level.lower(), + "level": normalized.lower(), "message": redact_text(message), "run_id": self.run_id, } diff --git a/logicytics/module/redaction.py b/logicytics/module/redaction.py index 08ed389e..819a0fbd 100644 --- a/logicytics/module/redaction.py +++ b/logicytics/module/redaction.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import re from collections.abc import Mapping from typing import Any @@ -48,7 +49,17 @@ def redact_text(value: str) -> str: """Remove common inline assignments, bearer credentials, and private-key blocks.""" value = _PRIVATE_KEY_PATTERN.sub(REDACTED, value) value = _BEARER_PATTERN.sub(lambda match: f"{match.group(1)}{REDACTED}", value) - return _ASSIGNMENT_PATTERN.sub(lambda match: f"{match.group(1)}{REDACTED}", value) + value = _ASSIGNMENT_PATTERN.sub(lambda match: f"{match.group(1)}{REDACTED}", value) + stripped = value.strip() + if stripped.startswith(("{", "[")) and stripped.endswith(("}", "]")): + try: + parsed = json.loads(stripped) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, (dict, list)): + sanitized = _redact_value(parsed) + return json.dumps(sanitized, ensure_ascii=True, sort_keys=True) + return value def _redact_value(value: Any) -> Any: diff --git a/tests/test_logging.py b/tests/test_logging.py index 16f2f277..ef88753c 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -81,7 +81,7 @@ def isatty(self) -> bool: self.assertIn("typed event", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) first_row = contents.splitlines()[0] - self.assertRegex(first_row, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} \|") + self.assertRegex(first_row, r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})? \|") self.assertLessEqual(max(map(len, contents.splitlines())), 140) with self.assertRaisesRegex(ValueError, "unsupported log level"): logger.event("TRACE", "unsupported") @@ -186,6 +186,43 @@ def isatty(self) -> bool: self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) + def test_application_logging_humanizes_structured_values_and_lowercase_settings(self) -> None: + """Human sinks normalize structured messages, collections, binary values, and levels.""" + with tempfile.TemporaryDirectory() as temporary: + console = io.StringIO() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(level="debug", color_enabled=False), + console=console, + ) + logger.event( + "info", + '{"status": "complete", "token": "secret-value"}', + capabilities={"network", "subprocess"}, + payload=b"binary evidence", + ) + + rendered = console.getvalue() + file_contents = (Path(temporary) / "Logicytics.log").read_text(encoding="utf-8") + for output in (rendered, file_contents): + self.assertIn("Structured details", output) + self.assertIn("Status: complete", output) + self.assertIn("Capabilities: network, subprocess", output) + self.assertIn("Payload: <15 bytes>", output) + self.assertNotIn("{\"status\"", output) + self.assertNotIn("capabilities=", output) + self.assertNotIn("secret-value", output) + + def test_application_logging_rejects_invalid_configured_level(self) -> None: + """A direct logger API cannot silently fail because of an invalid level policy.""" + with tempfile.TemporaryDirectory() as temporary: + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(level="trace", console_enabled=False), + ) + with self.assertRaisesRegex(ValueError, "unsupported log level"): + logger.event("INFO", "event") + def test_application_logging_wraps_long_plain_sections(self) -> None: """Human summaries keep long diagnostics readable within the console width.""" with tempfile.TemporaryDirectory() as temporary: @@ -305,6 +342,8 @@ def test_structured_event_logger_redacts_secret_fields_and_inline_credentials(se self.assertEqual("[REDACTED]", event["fields"]["password"]) self.assertEqual("[REDACTED]", event["fields"]["api_key"]) self.assertEqual("safe", event["fields"]["ordinary"]) + with self.assertRaisesRegex(ValueError, "unsupported log level"): + logger.event("TRACE", "unsupported") def test_structured_event_logger_serializes_concurrent_jsonl_events(self) -> None: """Concurrent diagnostics stay complete, independently parseable, and redacted.""" From 3f0cf1bbc18042aa4ca5e588840de84533dc6be3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:21:25 +0300 Subject: [PATCH 258/316] fix(cli): require a virtual environment before command loading Guard public CLI and test-runner entry points before loading application modules. Direct users to activate an existing .venv or run the installer when it is absent. --- README.md | 13 +++++++- logicytics/cli/__init__.py | 34 ++++++++++++++++++++- logicytics/cli/__main__.py | 2 +- logicytics/cli/commands.py | 11 ++++--- logicytics/cli/tests.py | 33 ++++++++++---------- logicytics/virtual_environment.py | 37 ++++++++++++++++++++++ tests/test_cli.py | 34 +++++++++++++++++++++ tests/test_test_runner_cli.py | 51 ++++++++++++++++++++++++++----- 8 files changed, 183 insertions(+), 32 deletions(-) create mode 100644 logicytics/virtual_environment.py diff --git a/README.md b/README.md index 9313c8e1..5dc3df79 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ The installer is the only Logicytics command designed to run outside a virtual e ```powershell python -m logicytics.cli.installer +.\.venv\Scripts\Activate.ps1 python -m logicytics preflight ``` @@ -42,6 +43,7 @@ python -m logicytics run --profile standard --acknowledge-authorization Use the bundled runner for the complete dynamically discovered test suite: ```powershell +.\.venv\Scripts\Activate.ps1 python -m logicytics.cli.tests ``` @@ -323,7 +325,15 @@ python -m logicytics.cli.installer --overwrite-config | --environment PATH | Virtual environment directory; default is .venv. Relative paths use the repository root. | | --overwrite-config | Replace the root YAML template with default values. | -After installation, use .venv\Scripts\python.exe for normal commands. +After installation, activate the environment before running a normal command: + +```powershell +.\.venv\Scripts\Activate.ps1 +``` + +If the environment is already present, every normal command prints this same +activation instruction instead of running outside it. If `.venv` is absent or +incomplete, it directs you to the installer instead. ### Test runner @@ -331,6 +341,7 @@ The test runner discovers the complete tests package, logs suite lifecycle, and returns CI-friendly status. It requires the managed virtual environment. ```powershell +.\.venv\Scripts\Activate.ps1 python -m logicytics.cli.tests python -m logicytics.cli.tests --verbosity 0 python -m logicytics.cli.tests --verbosity 1 diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 004a22b2..1feeadd5 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -1,5 +1,37 @@ """User-facing Logicytics command entry points.""" -from logicytics.cli.commands import CLI, cli_methods, main +from __future__ import annotations + +import sys +from pathlib import Path + +from logicytics.virtual_environment import ( + is_running_in_virtual_environment, + render_virtual_environment_error, +) __all__ = ["CLI", "cli_methods", "main"] + + +def _project_root() -> Path: + """Return the repository root without importing the normal CLI implementation.""" + return Path(__file__).resolve().parents[2] + + +def main(argv: list[str] | None = None) -> int: + """Guard the interpreter before loading the full CLI implementation.""" + if not is_running_in_virtual_environment(): + render_virtual_environment_error(sys.stderr, _project_root()) + return 2 + from logicytics.cli.commands import main as command_main + + return command_main(argv) + + +def __getattr__(name: str) -> object: + """Load the normal CLI API only when a caller asks for it.""" + if name in {"CLI", "cli_methods"}: + from logicytics.cli.commands import CLI, cli_methods + + return {"CLI": CLI, "cli_methods": cli_methods}[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/logicytics/cli/__main__.py b/logicytics/cli/__main__.py index 678cf214..f8991e1e 100644 --- a/logicytics/cli/__main__.py +++ b/logicytics/cli/__main__.py @@ -1,6 +1,6 @@ """Run the normal Logicytics CLI with ``python -m logicytics.cli``.""" -from logicytics.cli.commands import main +from logicytics.cli import main if __name__ == "__main__": raise SystemExit(main()) diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 32eed58d..79a778e6 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -42,6 +42,10 @@ from logicytics.module.runtime import RunSupervisor from logicytics.module.sysinternals import ensure_sysinternals from logicytics.platform_adapters import process_adapter +from logicytics.virtual_environment import ( + is_running_in_virtual_environment, + virtual_environment_error, +) class CLI: @@ -625,14 +629,11 @@ def run_developer_action( def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" - if sys.prefix == sys.base_prefix: + if not is_running_in_virtual_environment(): ApplicationLogger.render_section( sys.stderr, "Logicytics startup error", - ( - "Logicytics must run inside a virtual environment.", - "Next step: run python -m logicytics.cli.installer first.", - ), + virtual_environment_error(CLI.project_root()), ) return 2 cli_parser = cli_methods.parser() diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index 9fc04f98..624fda9e 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -8,10 +8,10 @@ import unittest from pathlib import Path -from logicytics.module.configuration import load_config -from logicytics.module.errors import PlanError -from logicytics.module.logging import ApplicationLogger, HumanArgumentParser, get_application_logger -from logicytics.module.output_layout import ensure_output_layout +from logicytics.virtual_environment import ( + is_running_in_virtual_environment, + render_virtual_environment_error, +) def project_root() -> Path: @@ -19,23 +19,24 @@ def project_root() -> Path: return Path(__file__).resolve().parents[2] -def _require_virtual_environment() -> None: - """Reject normal tooling execution outside the environment prepared by installer.""" - if sys.prefix == sys.base_prefix: - raise RuntimeError("Logicytics tests require a virtual environment. Run logicytics.cli.installer first.") - - def main(argv: list[str] | None = None) -> int: """Dynamically discover tests and return a CI-appropriate result code.""" + root = project_root() + if not is_running_in_virtual_environment(): + render_virtual_environment_error(sys.stderr, root) + return 2 + from logicytics.module.configuration import load_config + from logicytics.module.errors import PlanError + from logicytics.module.logging import ( + ApplicationLogger, + HumanArgumentParser, + get_application_logger, + ) + from logicytics.module.output_layout import ensure_output_layout + parser = HumanArgumentParser(description="Run all discovered Logicytics tests.") parser.add_argument("--verbosity", type=int, choices=(0, 1, 2), default=2) arguments = parser.parse_args(argv) - try: - _require_virtual_environment() - except RuntimeError as error: - ApplicationLogger.render_section(sys.stderr, "Test runner error", (str(error),)) - return 2 - root = project_root() try: configuration = load_config(root) layout = ensure_output_layout(configuration.runtime.output_root) diff --git a/logicytics/virtual_environment.py b/logicytics/virtual_environment.py new file mode 100644 index 00000000..ce7fcc33 --- /dev/null +++ b/logicytics/virtual_environment.py @@ -0,0 +1,37 @@ +"""Virtual-environment guardrails shared by runnable Logicytics commands.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import TextIO + + +_LOCAL_ENVIRONMENT = Path(".venv") +_ACTIVATION_SCRIPT = Path("Scripts") / "Activate.ps1" + + +def is_running_in_virtual_environment() -> bool: + """Return whether the active interpreter belongs to a virtual environment.""" + return sys.prefix != sys.base_prefix + + +def virtual_environment_error(root: Path) -> tuple[str, str]: + """Give the actionable next step for a command started outside a virtual environment.""" + activation_script = root / _LOCAL_ENVIRONMENT / _ACTIVATION_SCRIPT + if activation_script.is_file(): + return ( + "Logicytics must run inside a virtual environment.", + r"Next step: .\.venv\Scripts\Activate.ps1", + ) + return ( + "Logicytics must run inside a virtual environment.", + "Next step: run python -m logicytics.cli.installer first.", + ) + + +def render_virtual_environment_error(stream: TextIO, root: Path) -> None: + """Write the startup guard error without importing the normal CLI stack.""" + stream.write("Logicytics startup error\n") + for detail in virtual_environment_error(root): + stream.write(f" {detail}\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index ddac1903..dd79b490 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -18,6 +18,40 @@ class CliTests(unittest.TestCase): """CLI modes, parser constraints, reruns, and launch behavior.""" + def test_cli_requires_activating_the_existing_local_environment(self) -> None: + """A local environment gets an activation instruction instead of an installer prompt.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + activation_script = root / ".venv" / "Scripts" / "Activate.ps1" + activation_script.parent.mkdir(parents=True) + activation_script.write_text("", encoding="utf-8") + output = io.StringIO() + + with patch("logicytics.cli._project_root", return_value=root), patch.object( + sys, + "prefix", + sys.base_prefix, + ), patch("sys.stderr", output): + self.assertEqual(2, main(["preflight"])) + + self.assertIn(r".\.venv\Scripts\Activate.ps1", output.getvalue()) + self.assertNotIn("installer first", output.getvalue()) + + def test_cli_requires_the_installer_when_the_local_environment_is_missing(self) -> None: + """A missing or incomplete local environment gets the installer instruction.""" + with tempfile.TemporaryDirectory() as temporary: + output = io.StringIO() + + with patch("logicytics.cli._project_root", return_value=Path(temporary)), patch.object( + sys, + "prefix", + sys.base_prefix, + ), patch("sys.stderr", output): + self.assertEqual(2, main(["preflight"])) + + rendered = " ".join(output.getvalue().split()) + self.assertIn("python -m logicytics.cli.installer", rendered) + def test_run_parser_accepts_performance_check(self) -> None: """The run command must expose the performance mode used by the request builder.""" arguments = cli_methods.parser().parse_args(["run", "--performance-check"]) diff --git a/tests/test_test_runner_cli.py b/tests/test_test_runner_cli.py index ff71a1ac..92bb0cd9 100644 --- a/tests/test_test_runner_cli.py +++ b/tests/test_test_runner_cli.py @@ -1,6 +1,10 @@ from __future__ import annotations +import io +import sys +import tempfile import unittest +from pathlib import Path from unittest.mock import MagicMock, patch from logicytics.cli import tests as test_runner @@ -9,6 +13,39 @@ class TestRunnerCliTests(unittest.TestCase): """Presentation and result handling for the dynamically discovered test command.""" + def test_runner_requires_activating_the_existing_local_environment(self) -> None: + """The test script must not run outside the local virtual environment.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + activation_script = root / ".venv" / "Scripts" / "Activate.ps1" + activation_script.parent.mkdir(parents=True) + activation_script.write_text("", encoding="utf-8") + output = io.StringIO() + + with patch.object(test_runner, "project_root", return_value=root), patch.object( + sys, + "prefix", + sys.base_prefix, + ), patch("sys.stderr", output): + self.assertEqual(2, test_runner.main([])) + + self.assertIn(r".\.venv\Scripts\Activate.ps1", output.getvalue()) + + def test_runner_requires_the_installer_when_the_local_environment_is_missing(self) -> None: + """The test script points to the installer when no usable local environment exists.""" + with tempfile.TemporaryDirectory() as temporary: + output = io.StringIO() + + with patch.object(test_runner, "project_root", return_value=Path(temporary)), patch.object( + sys, + "prefix", + sys.base_prefix, + ), patch("sys.stderr", output): + self.assertEqual(2, test_runner.main([])) + + rendered = " ".join(output.getvalue().split()) + self.assertIn("python -m logicytics.cli.installer", rendered) + def test_runner_reports_discovered_suite_through_the_application_console(self) -> None: """A successful dynamic run renders a line-based summary and returns CI success.""" result = MagicMock() @@ -21,10 +58,9 @@ def test_runner_reports_discovered_suite_through_the_application_console(self) - runner = MagicMock() runner.run.return_value = result - with patch.object(test_runner, "_require_virtual_environment"), \ - patch.object(test_runner, "load_config", return_value=MagicMock()), \ - patch.object(test_runner, "ensure_output_layout", return_value=MagicMock()), \ - patch.object(test_runner, "get_application_logger", return_value=logger), \ + with patch("logicytics.module.configuration.load_config", return_value=MagicMock()), \ + patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), \ + patch("logicytics.module.logging.get_application_logger", return_value=logger), \ patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite()), \ patch.object(test_runner.unittest, "TextTestRunner", return_value=runner): self.assertEqual(0, test_runner.main(["--verbosity", "0"])) @@ -56,10 +92,9 @@ def run(self, _: unittest.TestSuite) -> MagicMock: self.stream.write("FAIL: example test\nassertion failed\n") return result - with patch.object(test_runner, "_require_virtual_environment"), \ - patch.object(test_runner, "load_config", return_value=MagicMock()), \ - patch.object(test_runner, "ensure_output_layout", return_value=MagicMock()), \ - patch.object(test_runner, "get_application_logger", return_value=logger), \ + with patch("logicytics.module.configuration.load_config", return_value=MagicMock()), \ + patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), \ + patch("logicytics.module.logging.get_application_logger", return_value=logger), \ patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite()), \ patch.object(test_runner.unittest, "TextTestRunner", FailingRunner): self.assertEqual(1, test_runner.main([])) From c291001c8d652ecb4adbe65655ea2c76f5889065 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:26:58 +0300 Subject: [PATCH 259/316] test(logging): prevent direct production print calls Assert that Logicytics and core Python sources use the structured logging system rather than print(). --- tests/test_logging.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_logging.py b/tests/test_logging.py index ef88753c..b01fa940 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import io import json import os @@ -31,6 +32,23 @@ class LoggingTests(unittest.TestCase): """Logging, output layout, file listing, and command-runner behavior.""" + def test_application_sources_do_not_call_print(self) -> None: + """Production output must use structured logging instead of direct console prints.""" + root = Path(__file__).resolve().parent.parent + violations: list[str] = [] + for source_root in (root / "logicytics", root / "core"): + for source in source_root.rglob("*.py"): + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + violations.extend( + f"{source.relative_to(root)}:{node.lineno}" + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "print" + ) + + self.assertEqual([], violations) + def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: """The application sink is typed, redacted, reusable, colored, and size bounded.""" From 5ca17537072cd0de9c6e4680b9fa2c298495242f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:35:31 +0300 Subject: [PATCH 260/316] refactor(logging): share bootstrap console formatting Route virtual-environment startup errors through the same redacted section renderer used by ApplicationLogger while preserving safe early startup behavior. --- logicytics/module/logging.py | 30 ++++----------- logicytics/module/presentation.py | 62 +++++++++++++++++++++++++++++++ logicytics/virtual_environment.py | 8 ++-- tests/test_logging.py | 20 ++++++++++ 4 files changed, 94 insertions(+), 26 deletions(-) create mode 100644 logicytics/module/presentation.py diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index f3383338..665483ea 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -18,6 +18,7 @@ from logicytics.contracts import EventLogger from logicytics.module.configuration import LoggingSettings +from logicytics.module.presentation import render_section as render_presentation_section from logicytics.module.redaction import redact_mapping, redact_text Parameters = ParamSpec("Parameters") @@ -321,28 +322,13 @@ def render_section( lines: Iterable[str], ) -> None: """Render a plain, indented console section for startup and fallback paths.""" - rows = list(cls._message_lines(title)) - for line in lines: - for raw_row in line.splitlines() or [""]: - safe_row = redact_text(raw_row) - indentation = safe_row[:len(safe_row) - len(safe_row.lstrip())] - remaining = safe_row.lstrip().rstrip() - prefix = f" {indentation}" - continuation_prefix = f" {indentation}" - available = max(cls._console_width() - len(continuation_prefix), 1) - presentation_rows = cls._message_lines(remaining) - for presentation_index, presentation_row in enumerate(presentation_rows): - wrapped = textwrap.wrap( - presentation_row, - width=available, - break_long_words=True, - break_on_hyphens=False, - ) or [""] - row_prefix = prefix if presentation_index == 0 else continuation_prefix - rows.append(row_prefix + wrapped[0]) - rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) - console.write("\n".join(rows) + "\n") - console.flush() + render_presentation_section( + console, + title, + lines, + message_lines=cls._message_lines, + width=cls._console_width, + ) def box(self, title: str, lines: Iterable[str]) -> None: """Render console-only output as plain redacted lines.""" diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py new file mode 100644 index 00000000..ed38dd53 --- /dev/null +++ b/logicytics/module/presentation.py @@ -0,0 +1,62 @@ +"""Shared redacted console presentation for logging and bootstrap errors.""" + +from __future__ import annotations + +import shutil +import textwrap +from collections.abc import Callable, Iterable +from typing import TextIO + +from logicytics.module.redaction import redact_text + + +_DEFAULT_CONSOLE_WIDTH = 100 +_MIN_CONSOLE_WIDTH = 40 +_RIGHT_EDGE_MARGIN = 2 + +MessageLines = Callable[[str], tuple[str, ...]] +ConsoleWidth = Callable[[], int] + + +def console_width() -> int: + """Return the shared terminal width with a safety margin and minimum.""" + width = shutil.get_terminal_size((_DEFAULT_CONSOLE_WIDTH, 24)).columns + return max(width - _RIGHT_EDGE_MARGIN, _MIN_CONSOLE_WIDTH) + + +def _plain_message_lines(message: str) -> tuple[str, ...]: + """Render bootstrap text safely without importing the full logger stack.""" + return (redact_text(message),) + + +def render_section( + console: TextIO, + title: str, + lines: Iterable[str], + *, + message_lines: MessageLines = _plain_message_lines, + width: ConsoleWidth = console_width, +) -> None: + """Render the shared indented, wrapped console section presentation.""" + rows = list(message_lines(title)) + for line in lines: + for raw_row in line.splitlines() or [""]: + safe_row = redact_text(raw_row) + indentation = safe_row[:len(safe_row) - len(safe_row.lstrip())] + remaining = safe_row.lstrip().rstrip() + prefix = f" {indentation}" + continuation_prefix = f" {indentation}" + available = max(width() - len(continuation_prefix), 1) + presentation_rows = message_lines(remaining) + for presentation_index, presentation_row in enumerate(presentation_rows): + wrapped = textwrap.wrap( + presentation_row, + width=available, + break_long_words=True, + break_on_hyphens=False, + ) or [""] + row_prefix = prefix if presentation_index == 0 else continuation_prefix + rows.append(row_prefix + wrapped[0]) + rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) + console.write("\n".join(rows) + "\n") + console.flush() diff --git a/logicytics/virtual_environment.py b/logicytics/virtual_environment.py index ce7fcc33..069c9600 100644 --- a/logicytics/virtual_environment.py +++ b/logicytics/virtual_environment.py @@ -6,6 +6,8 @@ from pathlib import Path from typing import TextIO +from logicytics.module.presentation import render_section + _LOCAL_ENVIRONMENT = Path(".venv") _ACTIVATION_SCRIPT = Path("Scripts") / "Activate.ps1" @@ -31,7 +33,5 @@ def virtual_environment_error(root: Path) -> tuple[str, str]: def render_virtual_environment_error(stream: TextIO, root: Path) -> None: - """Write the startup guard error without importing the normal CLI stack.""" - stream.write("Logicytics startup error\n") - for detail in virtual_environment_error(root): - stream.write(f" {detail}\n") + """Render the startup guard through the shared logging presentation format.""" + render_section(stream, "Logicytics startup error", virtual_environment_error(root)) diff --git a/tests/test_logging.py b/tests/test_logging.py index b01fa940..7907e118 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -26,6 +26,7 @@ timed, ) from logicytics.module.output_layout import ensure_output_layout +from logicytics.virtual_environment import render_virtual_environment_error, virtual_environment_error from tests.fixtures.file_listing import list_files @@ -49,6 +50,25 @@ def test_application_sources_do_not_call_print(self) -> None: self.assertEqual([], violations) + def test_virtual_environment_error_uses_the_logging_section_formatter(self) -> None: + """Bootstrap errors retain the exact redacted, wrapped logger presentation.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + activation_script = root / ".venv" / "Scripts" / "Activate.ps1" + activation_script.parent.mkdir(parents=True) + activation_script.write_text("", encoding="utf-8") + expected = io.StringIO() + actual = io.StringIO() + + ApplicationLogger.render_section( + expected, + "Logicytics startup error", + virtual_environment_error(root), + ) + render_virtual_environment_error(actual, root) + + self.assertEqual(expected.getvalue(), actual.getvalue()) + def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: """The application sink is typed, redacted, reusable, colored, and size bounded.""" From 9b70a3ba544823eaec3cc3dba37afc0dab6830ad Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:39:19 +0300 Subject: [PATCH 261/316] feat(logging): render startup failures as alerts Present virtual-environment startup failures in a framed, severity-marked logger alert with Unicode and ASCII console fallbacks. --- logicytics/module/logging.py | 29 ++++++++++---- logicytics/module/presentation.py | 66 +++++++++++++++++++++++++++++-- logicytics/virtual_environment.py | 4 +- tests/test_logging.py | 9 +++-- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 665483ea..77fbf1a2 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -6,7 +6,6 @@ import json import math import re -import shutil import sys import textwrap import traceback @@ -18,7 +17,11 @@ from logicytics.contracts import EventLogger from logicytics.module.configuration import LoggingSettings -from logicytics.module.presentation import render_section as render_presentation_section +from logicytics.module.presentation import ( + console_width as presentation_console_width, + render_alert as render_presentation_alert, + render_section as render_presentation_section, +) from logicytics.module.redaction import redact_mapping, redact_text Parameters = ParamSpec("Parameters") @@ -47,9 +50,6 @@ _TIME_WIDTH = 23 _SEVERITY_WIDTH = 9 _SOURCE_WIDTH = 28 -_DEFAULT_CONSOLE_WIDTH = 82 -_MIN_CONSOLE_WIDTH = 60 -_RIGHT_EDGE_MARGIN = 4 _RECORD_START = re.compile(rb"(?m)^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})? \|") _EVENT_NAME = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)+$") _LOGGER_LOCK = RLock() @@ -144,8 +144,7 @@ def _rows(level: str, source: str, message: str) -> tuple[str, ...]: @staticmethod def _console_width() -> int: """Return the AIBrain console width with its safety margin and minimum.""" - width = shutil.get_terminal_size((_DEFAULT_CONSOLE_WIDTH, 24)).columns - return max(width - _RIGHT_EDGE_MARGIN, _MIN_CONSOLE_WIDTH) + return presentation_console_width() def _supports_unicode(self) -> bool: """Return whether this console can encode AIBrain's presentation glyphs.""" @@ -330,6 +329,22 @@ def render_section( width=cls._console_width, ) + @classmethod + def render_alert( + cls, + console: TextIO, + title: str, + lines: Iterable[str], + ) -> None: + """Render a bordered startup or error alert using the shared logger presentation.""" + render_presentation_alert( + console, + title, + lines, + message_lines=cls._message_lines, + width=cls._console_width, + ) + def box(self, title: str, lines: Iterable[str]) -> None: """Render console-only output as plain redacted lines.""" with self._lock: diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index ed38dd53..82d141fc 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -10,9 +10,9 @@ from logicytics.module.redaction import redact_text -_DEFAULT_CONSOLE_WIDTH = 100 -_MIN_CONSOLE_WIDTH = 40 -_RIGHT_EDGE_MARGIN = 2 +_DEFAULT_CONSOLE_WIDTH = 82 +_MIN_CONSOLE_WIDTH = 60 +_RIGHT_EDGE_MARGIN = 4 MessageLines = Callable[[str], tuple[str, ...]] ConsoleWidth = Callable[[], int] @@ -29,6 +29,17 @@ def _plain_message_lines(message: str) -> tuple[str, ...]: return (redact_text(message),) +def _alert_symbols(console: TextIO) -> tuple[str, str, str, str, str, str, str, str]: + """Use Unicode alert framing when the destination can encode it.""" + encoding = getattr(console, "encoding", None) or "utf-8" + symbols = ("╭", "─", "╮", "│", "╰", "╯", "×", "→") + try: + "".join(symbols).encode(encoding) + except (LookupError, UnicodeEncodeError): + return ("+", "-", "+", "|", "+", "+", "!", ">") + return symbols + + def render_section( console: TextIO, title: str, @@ -60,3 +71,52 @@ def render_section( rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) console.write("\n".join(rows) + "\n") console.flush() + + +def render_alert( + console: TextIO, + title: str, + lines: Iterable[str], + *, + message_lines: MessageLines = _plain_message_lines, + width: ConsoleWidth = console_width, +) -> None: + """Render a severity-marked, bordered alert with wrapped actionable details.""" + ( + top_left, + horizontal, + top_right, + vertical, + bottom_left, + bottom_right, + error_marker, + next_step_marker, + ) = _alert_symbols(console) + inner_width = min(max(width(), _MIN_CONSOLE_WIDTH), 88) - 2 + safe_title = redact_text(title).strip() + heading = f" {safe_title} "[:inner_width] + rows = [ + f"{top_left}{heading}{horizontal * max(inner_width - len(heading), 0)}{top_right}", + ] + for line_index, line in enumerate(lines): + marker = error_marker if line_index == 0 else next_step_marker + prefix = f" {marker} " + continuation_prefix = " " + for raw_row in line.splitlines() or [""]: + for presentation_row in message_lines(raw_row.strip()): + wrapped = textwrap.wrap( + presentation_row, + width=max(inner_width - len(prefix), 1), + break_long_words=True, + break_on_hyphens=False, + ) or [""] + rows.append( + f"{vertical}{prefix}{wrapped[0]:<{inner_width - len(prefix)}}{vertical}" + ) + rows.extend( + f"{vertical}{continuation_prefix}{row:<{inner_width - len(continuation_prefix)}}{vertical}" + for row in wrapped[1:] + ) + rows.append(f"{bottom_left}{horizontal * inner_width}{bottom_right}") + console.write("\n".join(rows) + "\n") + console.flush() diff --git a/logicytics/virtual_environment.py b/logicytics/virtual_environment.py index 069c9600..1dd30cd8 100644 --- a/logicytics/virtual_environment.py +++ b/logicytics/virtual_environment.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import TextIO -from logicytics.module.presentation import render_section +from logicytics.module.presentation import render_alert _LOCAL_ENVIRONMENT = Path(".venv") @@ -34,4 +34,4 @@ def virtual_environment_error(root: Path) -> tuple[str, str]: def render_virtual_environment_error(stream: TextIO, root: Path) -> None: """Render the startup guard through the shared logging presentation format.""" - render_section(stream, "Logicytics startup error", virtual_environment_error(root)) + render_alert(stream, "Logicytics startup error", virtual_environment_error(root)) diff --git a/tests/test_logging.py b/tests/test_logging.py index 7907e118..e726752a 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -50,8 +50,8 @@ def test_application_sources_do_not_call_print(self) -> None: self.assertEqual([], violations) - def test_virtual_environment_error_uses_the_logging_section_formatter(self) -> None: - """Bootstrap errors retain the exact redacted, wrapped logger presentation.""" + def test_virtual_environment_error_uses_the_logging_alert_formatter(self) -> None: + """Bootstrap errors retain the exact redacted, wrapped logger alert presentation.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) activation_script = root / ".venv" / "Scripts" / "Activate.ps1" @@ -60,7 +60,7 @@ def test_virtual_environment_error_uses_the_logging_section_formatter(self) -> N expected = io.StringIO() actual = io.StringIO() - ApplicationLogger.render_section( + ApplicationLogger.render_alert( expected, "Logicytics startup error", virtual_environment_error(root), @@ -68,6 +68,9 @@ def test_virtual_environment_error_uses_the_logging_section_formatter(self) -> N render_virtual_environment_error(actual, root) self.assertEqual(expected.getvalue(), actual.getvalue()) + self.assertIn("╭", actual.getvalue()) + self.assertIn("│ ×", actual.getvalue()) + self.assertIn("│ →", actual.getvalue()) def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: """The application sink is typed, redacted, reusable, colored, and size bounded.""" From 33623a1039e6de06ccd7206d36ec22ae7b16f806 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:39:33 +0300 Subject: [PATCH 262/316] fix(cli): format direct startup guard as alert --- logicytics/cli/commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 79a778e6..7703002d 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -630,7 +630,7 @@ def run_developer_action( def main(argv: list[str] | None = None) -> int: """Run the selected preflight, planning, or supervised execution command.""" if not is_running_in_virtual_environment(): - ApplicationLogger.render_section( + ApplicationLogger.render_alert( sys.stderr, "Logicytics startup error", virtual_environment_error(CLI.project_root()), From 8dd1b57ad5b9669e28578e00d4d296bae1c89e6a Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:43:40 +0300 Subject: [PATCH 263/316] feat(cli): manage interactive terminal lifecycle Clear interactive Windows consoles with cls before user-facing commands and leave one final newline on normal exit or handled interruption without altering redirected worker output. Signed-off-by: DefinetlyNotAI --- README.md | 3 ++ logicytics/cli/__init__.py | 12 ++++--- logicytics/cli/commands.py | 4 ++- logicytics/cli/installer.py | 4 ++- logicytics/cli/tests.py | 4 ++- logicytics/terminal.py | 39 +++++++++++++++++++++ tests/test_terminal.py | 69 +++++++++++++++++++++++++++++++++++++ 7 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 logicytics/terminal.py create mode 100644 tests/test_terminal.py diff --git a/README.md b/README.md index 5dc3df79..15784855 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ These flags go before the action name: All normal command output uses the Logicytics console presentation: a severity marker, a short message, and readable indented fields. Structured JSON is kept in the documented artifact files and is never dumped directly to the console. +Each user-facing command clears an interactive terminal before it starts and +leaves one final newline when it returns; redirected and worker-protocol output +is left untouched. INFO text is white, DEBUG text is gray, warnings are yellow, and failures are red. Long messages wrap to the terminal width; application log fields are written as readable continuation lines with millisecond timestamps. Argument diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 1feeadd5..525c3701 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -9,6 +9,7 @@ is_running_in_virtual_environment, render_virtual_environment_error, ) +from logicytics.terminal import terminal_lifecycle __all__ = ["CLI", "cli_methods", "main"] @@ -20,12 +21,13 @@ def _project_root() -> Path: def main(argv: list[str] | None = None) -> int: """Guard the interpreter before loading the full CLI implementation.""" - if not is_running_in_virtual_environment(): - render_virtual_environment_error(sys.stderr, _project_root()) - return 2 - from logicytics.cli.commands import main as command_main + with terminal_lifecycle(): + if not is_running_in_virtual_environment(): + render_virtual_environment_error(sys.stderr, _project_root()) + return 2 + from logicytics.cli.commands import main as command_main - return command_main(argv) + return command_main(argv) def __getattr__(name: str) -> object: diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 7703002d..fa76ac93 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -42,6 +42,7 @@ from logicytics.module.runtime import RunSupervisor from logicytics.module.sysinternals import ensure_sysinternals from logicytics.platform_adapters import process_adapter +from logicytics.terminal import terminal_lifecycle from logicytics.virtual_environment import ( is_running_in_virtual_environment, virtual_environment_error, @@ -1249,4 +1250,5 @@ def finish_command( cli_methods = CLI() if __name__ == "__main__": - raise SystemExit(main()) + with terminal_lifecycle(): + raise SystemExit(main()) diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 30fa569b..0a1fc5b2 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -9,6 +9,7 @@ from logicytics.module.configuration import write_default_configuration from logicytics.module.logging import ApplicationLogger, HumanArgumentParser +from logicytics.terminal import terminal_lifecycle def project_root() -> Path: @@ -44,4 +45,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) + with terminal_lifecycle(): + raise SystemExit(main()) diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index 624fda9e..02e9c396 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -12,6 +12,7 @@ is_running_in_virtual_environment, render_virtual_environment_error, ) +from logicytics.terminal import terminal_lifecycle def project_root() -> Path: @@ -70,4 +71,5 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) + with terminal_lifecycle(): + raise SystemExit(main()) diff --git a/logicytics/terminal.py b/logicytics/terminal.py new file mode 100644 index 00000000..55ad53c0 --- /dev/null +++ b/logicytics/terminal.py @@ -0,0 +1,39 @@ +"""Interactive terminal lifecycle controls for user-facing Logicytics commands.""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Iterator +from contextlib import contextmanager + +_ACTIVE_SESSIONS = 0 + + +def _is_interactive_terminal() -> bool: + """Return whether this process owns a terminal that can be visibly managed.""" + return sys.stdout.isatty() or sys.stderr.isatty() + + +def _clear_terminal() -> None: + """Clear the shared terminal screen through the platform's real console command.""" + os.system("cls" if os.name == "nt" else "clear") + print() + + +@contextmanager +def terminal_lifecycle() -> Iterator[None]: + """Clear an interactive screen once and leave one final newline on normal unwind.""" + global _ACTIVE_SESSIONS + interactive = _is_interactive_terminal() + outermost = _ACTIVE_SESSIONS == 0 + if outermost and interactive: + _clear_terminal() + _ACTIVE_SESSIONS += 1 + try: + yield + finally: + _ACTIVE_SESSIONS -= 1 + if outermost and interactive: + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/tests/test_terminal.py b/tests/test_terminal.py new file mode 100644 index 00000000..7b14bc09 --- /dev/null +++ b/tests/test_terminal.py @@ -0,0 +1,69 @@ +"""Terminal lifecycle tests for user-facing command startup and completion.""" + +from __future__ import annotations + +import io +import unittest +from unittest.mock import patch + +from logicytics import terminal + + +class _InteractiveBuffer(io.StringIO): + """A writable test terminal that reports itself as interactive.""" + + def isatty(self) -> bool: + """Report an interactive terminal for lifecycle coverage.""" + return True + + +class TerminalLifecycleTests(unittest.TestCase): + """Interactive session clearing and final-newline behavior.""" + + def test_windows_clear_uses_the_real_console_command(self) -> None: + """Windows startup clears the shared screen buffer through cls, not escape bytes.""" + with patch.object(terminal.os, "name", "nt"), patch.object( + terminal.os, + "system", + return_value=0, + ) as system: + terminal._clear_terminal() + + system.assert_called_once_with("cls") + + def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None: + """The outer lifecycle performs real clearing once and leaves a clean prompt line.""" + output = _InteractiveBuffer() + errors = _InteractiveBuffer() + + with patch.object(terminal.sys, "stdout", output), patch.object( + terminal.sys, + "stderr", + errors, + ), patch.object(terminal, "_clear_terminal") as clear_terminal: + with terminal.terminal_lifecycle(): + with terminal.terminal_lifecycle(): + output.write("command output") + + clear_terminal.assert_called_once_with() + self.assertEqual("command output\n", output.getvalue()) + + def test_noninteractive_lifecycle_preserves_machine_output(self) -> None: + """Redirected output must not receive screen controls or an extra newline.""" + output = io.StringIO() + errors = io.StringIO() + + with patch.object(terminal.sys, "stdout", output), patch.object( + terminal.sys, + "stderr", + errors, + ), patch.object(terminal, "_clear_terminal") as clear_terminal: + with terminal.terminal_lifecycle(): + output.write("machine output") + + clear_terminal.assert_not_called() + self.assertEqual("machine output", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() From 5d205539505d12dc7e8312a4092649661a846d68 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:47:38 +0300 Subject: [PATCH 264/316] fix(terminal): avoid direct print during screen cleanup Use the terminal stream explicitly for lifecycle blank lines and verify cleanup after exceptions. --- logicytics/terminal.py | 3 ++- tests/test_terminal.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/logicytics/terminal.py b/logicytics/terminal.py index 55ad53c0..3bc84480 100644 --- a/logicytics/terminal.py +++ b/logicytics/terminal.py @@ -18,7 +18,8 @@ def _is_interactive_terminal() -> bool: def _clear_terminal() -> None: """Clear the shared terminal screen through the platform's real console command.""" os.system("cls" if os.name == "nt" else "clear") - print() + sys.stdout.write("\n") + sys.stdout.flush() @contextmanager diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 7b14bc09..4cf5f2b2 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -64,6 +64,24 @@ def test_noninteractive_lifecycle_preserves_machine_output(self) -> None: clear_terminal.assert_not_called() self.assertEqual("machine output", output.getvalue()) + def test_interactive_lifecycle_ends_with_a_newline_after_an_exception(self) -> None: + """An exception unwinds the terminal lifecycle and leaves a clean prompt line.""" + output = _InteractiveBuffer() + errors = _InteractiveBuffer() + + with patch.object(terminal.sys, "stdout", output), patch.object( + terminal.sys, + "stderr", + errors, + ), patch.object(terminal, "_clear_terminal") as clear_terminal: + with self.assertRaisesRegex(RuntimeError, "interrupted"): + with terminal.terminal_lifecycle(): + output.write("command output") + raise RuntimeError("interrupted") + + clear_terminal.assert_called_once_with() + self.assertEqual("command output\n", output.getvalue()) + if __name__ == "__main__": unittest.main() From c61c7c2f1b6493ff19adbbe27099a2dc1c84bbe4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:49:11 +0300 Subject: [PATCH 265/316] fix(terminal): leave cleared screen at its origin Reserve the lifecycle newline for normal and exception-driven shutdown. --- logicytics/terminal.py | 2 -- tests/test_terminal.py | 8 +++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/logicytics/terminal.py b/logicytics/terminal.py index 3bc84480..0fbc5654 100644 --- a/logicytics/terminal.py +++ b/logicytics/terminal.py @@ -18,8 +18,6 @@ def _is_interactive_terminal() -> bool: def _clear_terminal() -> None: """Clear the shared terminal screen through the platform's real console command.""" os.system("cls" if os.name == "nt" else "clear") - sys.stdout.write("\n") - sys.stdout.flush() @contextmanager diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 4cf5f2b2..a5bb65a3 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -22,7 +22,12 @@ class TerminalLifecycleTests(unittest.TestCase): def test_windows_clear_uses_the_real_console_command(self) -> None: """Windows startup clears the shared screen buffer through cls, not escape bytes.""" - with patch.object(terminal.os, "name", "nt"), patch.object( + output = _InteractiveBuffer() + with patch.object(terminal.sys, "stdout", output), patch.object( + terminal.os, + "name", + "nt", + ), patch.object( terminal.os, "system", return_value=0, @@ -30,6 +35,7 @@ def test_windows_clear_uses_the_real_console_command(self) -> None: terminal._clear_terminal() system.assert_called_once_with("cls") + self.assertEqual("", output.getvalue()) def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None: """The outer lifecycle performs real clearing once and leaves a clean prompt line.""" From 7ba3e83b934bd533008a0ae795989035c505f4c5 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 13:59:28 +0300 Subject: [PATCH 266/316] feat(mods): restrict extension scripts to Python Remove non-Python modes and adapters, reject legacy executable MOD files during preflight, and retain only the isolated Python runner. Signed-off-by: DefinetlyNotAI --- README.md | 2 - docs/FLOW_MATRIX.md | 5 +- docs/MIGRATION.md | 3 +- docs/MODS.md | 37 ++-- docs/V4_RELEASE.md | 9 +- logicytics/cli/commands.py | 5 - logicytics/contracts.py | 3 +- logicytics/module/command_runner.py | 2 +- logicytics/module/discovery.py | 18 +- logicytics/module/interaction.py | 1 - logicytics/module/modes.py | 15 +- logicytics/module/planner.py | 8 +- logicytics/module/runtime.py | 36 ++-- logicytics/terminal.py | 1 + tests/test_cli.py | 17 +- tests/test_flow_matrix.py | 1 - tests/test_mods.py | 255 +++------------------------- 17 files changed, 77 insertions(+), 341 deletions(-) diff --git a/README.md b/README.md index 15784855..79aefc0b 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,6 @@ exclusive. | thorough | deep | Configured bounded parallelism | None | | offline | offline | Configured bounded parallelism | None | | extensions | standard | Configured bounded parallelism | All valid plugins and MODs | -| non-python | standard | Configured bounded parallelism | Non-Python MODs only | | performance | standard | Sequential | Duration report enabled | ```powershell @@ -231,7 +230,6 @@ python -m logicytics run --mode performance --acknowledge-authorization | --minimal | quick | | --depth | thorough | | --modded | extensions | -| --nopy | non-python | | --performance-check | performance | ### Direct collector runs diff --git a/docs/FLOW_MATRIX.md b/docs/FLOW_MATRIX.md index 4b36f89a..46127788 100644 --- a/docs/FLOW_MATRIX.md +++ b/docs/FLOW_MATRIX.md @@ -11,7 +11,6 @@ tests normally. | threaded / balanced | `test_explicit_execution_modes_control_isolated_worker_overlap` | | minimal / quick | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | | deep / thorough | `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` | -| non-Python | `test_nopy_and_modded_modes_select_declared_mod_types_without_helpers` | | performance | `test_performance_report_is_finalized_before_automatic_packaging` | | modded / extensions | `test_mods_require_sidecars_and_run_as_isolated_registered_artifacts` | | debug | `test_dev_writes_explicit_manifest_and_debug_persists_diagnostics` | @@ -22,7 +21,7 @@ tests normally. | permission denied | `test_access_denied_is_skipped_not_failed` | The mode registry test covers named modes and every historical flag alias. The -overlap test executes real isolated workers. MOD and non-Python tests exercise -sidecar discovery and native adapters. Maintenance tests keep update/debug out of +overlap test executes real isolated workers. MOD tests exercise Python sidecar +discovery and isolated execution. Maintenance tests keep update/debug out of normal collection. Power actions require a verified package. Cancellation and permission tests prove these states cannot be misreported as success. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index d31bba64..f63bb359 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -15,7 +15,6 @@ The old mode flags remain explicit compatibility aliases. Their v4 equivalents a | `--minimal` | `--mode quick` | | `--depth` | `--mode thorough` | | `--modded` | `--mode extensions` | -| `--nopy` | `--mode non-python` | | `--performance-check` | `--mode performance` | Only one mode or alias may be selected per invocation. Prefer named `--mode` values in new automation. @@ -24,7 +23,7 @@ Only one mode or alias may be selected per invocation. Prefer named `--mode` val - `CODE/config.ini` is read only as a legacy compatibility input; v4 writes canonical manifests and does not mutate the file. - `core.integration.legacy_code_outputs` remains the explicit bridge for approved legacy `CODE` output. It is not an implicit scan of the repository. -- `MODS/` is an opt-in extension area. MOD sidecars declare the executable, capabilities, output paths, and media types before a run can select them. +- `MODS/` is an opt-in Python extension area. MOD sidecars declare the script, capabilities, output paths, and media types before a run can select them. - There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/` stores. Run-owned evidence is written below the configured `runtime.output_root` and each run has its own manifest, logs, artifacts, and package hashes. Do not copy v3 JSON settings into the v4 YAML path. Translate supported values using [CONFIGURATION.md](CONFIGURATION.md), then run `python -m logicytics preflight` before collecting. diff --git a/docs/MODS.md b/docs/MODS.md index c769b1f3..ceb94366 100644 --- a/docs/MODS.md +++ b/docs/MODS.md @@ -1,22 +1,22 @@ # MODS compatibility contract -`MODS/` is the opt-in compatibility area for legacy Python, PowerShell, batch, -and Windows executable collectors. Normal runs never execute these files. -Use `run --modded` to run the standard core profile followed by valid mods, or -`run --nopy` to run only `.ps1`, `.bat`, and `.exe` mods. +`MODS/` is the opt-in compatibility area for Python collectors. Normal runs +never execute these files. Use `run --modded` to run the standard core profile +followed by valid MODS payloads. Only Python `.py` scripts are accepted. ## Layout and discovery Mods are discovered recursively. Runnable filenames must be lowercase -`snake_case` and use `.py`, `.ps1`, `.bat`, or `.exe`. Files and directories -whose names begin with `_` are ignored. Every runnable file needs an adjacent +`snake_case` and use `.py`. PowerShell, batch, and executable payloads are +rejected during preflight. Files and directories whose names begin with `_` are +ignored. Every runnable file needs an adjacent sidecar named `..mod.json`; for example: ```text MODS/ inventory/ - local_report.ps1 - local_report.ps1.mod.json + local_report.py + local_report.py.mod.json ``` The sidecar must declare the complete immutable collector metadata contract. @@ -45,25 +45,21 @@ This example is intentionally bounded and non-sensitive: } ``` -The ID must be `mod.`. Legacy adapters must request the +The ID must be `mod.`. MODS scripts must request the `subprocess` capability. Other access must also be declared and remains subject to the active blocked-capability policy. Python MODs are audit-confined to their private workspace by default. -PowerShell, batch, and executable MODs must additionally declare -`filesystem_write`, because Python cannot enforce filesystem boundaries inside -native child processes. A Python MOD needs that capability -only when intentionally writing outside its workspace. Invalid or missing -sidecars quarantine the mod; selecting all mods -then fails preflight before any collector launches. +Python MODS need `filesystem_write` only when intentionally writing outside +their private workspace. Invalid or missing sidecars quarantine the MOD; +selecting all MODS then fails preflight before any collector launches. ## Execution and output Core collectors run before mods. Each mod receives its own worker process, private workspace, temporary directory, event channel, timeout, memory limit, artifact quota, cancellation signal, and result record. The source is copied -into the private workspace before execution. Python uses the configured running -interpreter, PowerShell uses a non-interactive execution-policy-bypass process, -batch uses `cmd.exe`, and executables launch directly without a command shell. +into the private workspace before execution and uses the configured Python +interpreter. Write generated evidence beneath the current working directory, exposed as `LOGICYTICS_WORKSPACE`. Only non-empty files whose MIME type appears in @@ -84,7 +80,6 @@ Mods are untrusted, opt-in local code. Worker/process isolation, a private workspace, declared-capability enforcement, bounded resources, packaging allowlists, and process-tree termination contain engine state and evidence publication. Python MOD mutation attempts outside the workspace fail unless -`filesystem_write` was declared. Native MOD types require that declaration -before preflight accepts them. A configured or command-line block still -overrides a declaration. Review a mod and its declared capabilities before +`filesystem_write` was declared. A configured or command-line block still +overrides a declaration. Review a MOD and its declared capabilities before executing it. diff --git a/docs/V4_RELEASE.md b/docs/V4_RELEASE.md index 59ca2198..7bae95a1 100644 --- a/docs/V4_RELEASE.md +++ b/docs/V4_RELEASE.md @@ -46,7 +46,7 @@ collectors. The canonical profiles are `minimal`, `standard`, `deep`, and `offline`. The canonical modes are `standard`, `balanced`, `quick`, `thorough`, `offline`, -`extensions`, `non-python`, and `performance`; all historical mode flags remain +`extensions` and `performance`; supported historical mode flags remain validated aliases. `python -m logicytics --modes` shows a human-readable mode summary and writes the authoritative machine-readable collector inclusion matrix to `output/logs/debug/modes.json`. @@ -63,10 +63,9 @@ is the complete parser-backed schema reference. Profiles, explicit selections, extensions, capabilities, authorization, scheduling, reruns, output policy, and power actions remain invocation-only. -Plugins and MODs remain opt-in extension points. Python, PowerShell, batch, and -executable MOD payloads require sidecars and use the same preflight, planning, -capability, isolation, artifact, failure, and packaging boundaries as core -collectors. Historical generated `CODE` evidence has one bounded compatibility +Plugins and Python MODs remain opt-in extension points. MOD payloads require +sidecars and use the same preflight, planning, capability, isolation, artifact, +failure, and packaging boundaries as core collectors. Historical generated `CODE` evidence has one bounded compatibility collector; source and executable material is excluded. ## Predictable and inspectable results diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index fa76ac93..ac1a8370 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -143,8 +143,6 @@ def request( mode.enable_mods if mode is not None else False ) - non_python_only = mode.non_python_only if mode is not None else False - requested_workers = getattr(arguments, "workers", None) if sequential and requested_workers is not None and requested_workers != 1: @@ -285,7 +283,6 @@ def request( selection_only=selection_only, enable_plugins=plugins_enabled, enable_mods=enable_mods, - non_python_only=non_python_only, max_workers=worker_count, acknowledge_authorization=getattr( arguments, @@ -390,8 +387,6 @@ def parser() -> argparse.ArgumentParser: mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") mode.add_argument("--modded", action="store_true", help="Run the standard profile plus all valid MODS scripts.") - mode.add_argument("--nopy", action="store_true", - help="Run only non-Python PowerShell, batch, and executable MODS scripts.") mode.add_argument( "--performance-check", action="store_true", diff --git a/logicytics/contracts.py b/logicytics/contracts.py index e3489873..be84e12b 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -487,7 +487,6 @@ class RunRequest: selection_only: bool = False enable_plugins: bool = False enable_mods: bool = False - non_python_only: bool = False max_workers: int = 4 acknowledge_authorization: bool = False blocked_capabilities: tuple[Capability, ...] = () @@ -521,7 +520,7 @@ def __post_init__(self) -> None: if self.rerun_from is not None and not self.include: raise ValueError("request rerun_from requires explicit included collector IDs") for name in ( - "selection_only", "enable_plugins", "enable_mods", "non_python_only", + "selection_only", "enable_plugins", "enable_mods", "acknowledge_authorization", "performance_check", ): if not isinstance(getattr(self, name), bool): diff --git a/logicytics/module/command_runner.py b/logicytics/module/command_runner.py index a94287f3..c88bfbac 100644 --- a/logicytics/module/command_runner.py +++ b/logicytics/module/command_runner.py @@ -32,7 +32,7 @@ def run_command(command: Iterable[str], *, timeout_seconds: float = 30) -> Comma def parse_level_messages(output: str) -> tuple[tuple[str, str], ...]: - """Parse non-Python `LEVEL: message` lines into normalized structured entries.""" + """Parse `LEVEL: message` lines into normalized structured entries.""" messages: list[tuple[str, str]] = [] for line in output.splitlines(): level, separator, message = line.partition(":") diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index 2042030c..43883ec1 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -17,8 +17,9 @@ from logicytics.platform_adapters import process_adapter _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") -_MOD_FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.(?:py|ps1|bat|exe)$") -_MOD_EXTENSIONS = {".py": "mod_python", ".ps1": "mod_powershell", ".bat": "mod_batch", ".exe": "mod_executable"} +_MOD_FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") +_MOD_EXTENSIONS = {".py": "mod_python"} +_REMOVED_MOD_EXTENSIONS = frozenset({".ps1", ".bat", ".exe"}) _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} _APPLICATION_IMPORTS = { "CollectorSnapshot", "RunSnapshot", "api", "artifacts", "cli", "configuration", "discovery", "environment", @@ -399,6 +400,9 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: if not _is_within(path, mods_root) or path.is_symlink(): candidate.static_errors.append("mod path must remain a regular file inside MODS") return candidate + if path.suffix.casefold() in _REMOVED_MOD_EXTENSIONS: + candidate.static_errors.append("MODS supports Python (.py) scripts only") + return candidate if not _MOD_FILENAME.fullmatch(path.name): candidate.static_errors.append("mod filename must be lowercase snake_case with a supported extension") sidecar = path.with_suffix(path.suffix + ".mod.json") @@ -433,14 +437,6 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: candidate.runtime_error = f"mod ID must match its filename: {expected_id}" elif Capability.SUBPROCESS not in metadata.capabilities: candidate.runtime_error = "legacy script mods must declare the subprocess capability" - elif ( - candidate.execution_type != "mod_python" - and Capability.FILESYSTEM_WRITE not in metadata.capabilities - ): - candidate.runtime_error = ( - "non-Python mods must declare filesystem_write because their host mutations " - "cannot be audit-confined" - ) else: candidate.metadata = metadata return candidate @@ -759,7 +755,7 @@ def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: relative = path.relative_to(mods_root) if any(part.startswith("_") for part in relative.parts): continue - if path.suffix.casefold() in _MOD_EXTENSIONS: + if path.suffix.casefold() in _MOD_EXTENSIONS or path.suffix.casefold() in _REMOVED_MOD_EXTENSIONS: candidates.append(_validate_mod(path, mods_root)) return tuple(candidates) diff --git a/logicytics/module/interaction.py b/logicytics/module/interaction.py index 06529bf4..cd1f2959 100644 --- a/logicytics/module/interaction.py +++ b/logicytics/module/interaction.py @@ -18,7 +18,6 @@ "threaded": "standard collection with bounded parallel workers", "minimal": "quick basic essential collection", "depth": "deep exhaustive slow collection", - "nopy": "non Python PowerShell executable and batch collectors", "modded": "normal collection plus opt in MODS extensions", "performance-check": "sequential collector duration performance analysis", "usage": "interaction statistics and flag usage graph", diff --git a/logicytics/module/modes.py b/logicytics/module/modes.py index e3169fb9..753ea1ab 100644 --- a/logicytics/module/modes.py +++ b/logicytics/module/modes.py @@ -55,7 +55,6 @@ class ExecutionMode: profile: str strategy: ExecutionStrategy = ExecutionStrategy.CONFIGURED enable_mods: bool = False - non_python_only: bool = False performance_check: bool = False @@ -81,13 +80,6 @@ class ExecutionMode: "standard", enable_mods=True, ), - ExecutionMode( - "non-python", - "Run only declared PowerShell, batch, and executable MODS payloads.", - "standard", - enable_mods=True, - non_python_only=True, - ), ExecutionMode( "performance", "Sequential standard collection with per-collector duration reporting.", @@ -106,7 +98,6 @@ class ExecutionMode: "minimal": "quick", "depth": "thorough", "modded": "extensions", - "nopy": "non-python", "performance_check": "performance", }) @@ -138,13 +129,11 @@ def _candidate_modes(candidate: CollectorCandidate) -> tuple[str, ...]: selected: list[str] = [] for mode in EXECUTION_MODES.values(): if candidate.kind is CollectorKind.MOD: - enabled = mode.enable_mods and not ( - mode.non_python_only and candidate.execution_type == "mod_python" - ) + enabled = mode.enable_mods elif candidate.kind is CollectorKind.PLUGIN: enabled = False else: - enabled = not mode.non_python_only and mode.profile in metadata.default_profiles + enabled = mode.profile in metadata.default_profiles if enabled: selected.append(mode.name) return tuple(selected) diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index e14ef83e..1a86e255 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -57,13 +57,7 @@ def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> if candidate.kind is CollectorKind.PLUGIN and not request.enable_plugins: return False if candidate.kind is CollectorKind.MOD: - if not request.enable_mods: - return False - if request.non_python_only and candidate.execution_type == "mod_python": - return False - return True - if request.non_python_only: - return False + return request.enable_mods return request.profile in metadata.default_profiles diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index 256815e6..bf3fddf8 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -425,27 +425,21 @@ def _mod_command( blocked_capabilities: tuple[Capability, ...] = (), ) -> list[str]: """Build a shell-free command for one copied legacy MODS script.""" - if execution_type == "mod_python": - if workspace is None: - raise ValueError("Python mod execution requires a private workspace") - runner = Path(__file__).with_name("mod_runner.py") - return [ - sys.executable, - "-I", - str(runner), - str(script), - str(workspace), - collector_id, - json.dumps([capability.value for capability in capabilities]), - json.dumps([capability.value for capability in blocked_capabilities]), - ] - if execution_type == "mod_powershell": - return ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(script)] - if execution_type == "mod_batch": - return ["cmd.exe", "/d", "/c", str(script)] - if execution_type == "mod_executable": - return [str(script)] - raise ValueError(f"unsupported mod execution type: {execution_type}") + if execution_type != "mod_python": + raise ValueError("MODS supports Python (.py) scripts only") + if workspace is None: + raise ValueError("Python mod execution requires a private workspace") + runner = Path(__file__).with_name("mod_runner.py") + return [ + sys.executable, + "-I", + str(runner), + str(script), + str(workspace), + collector_id, + json.dumps([capability.value for capability in capabilities]), + json.dumps([capability.value for capability in blocked_capabilities]), + ] def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) -> None: diff --git a/logicytics/terminal.py b/logicytics/terminal.py index 0fbc5654..55ad53c0 100644 --- a/logicytics/terminal.py +++ b/logicytics/terminal.py @@ -18,6 +18,7 @@ def _is_interactive_terminal() -> bool: def _clear_terminal() -> None: """Clear the shared terminal screen through the platform's real console command.""" os.system("cls" if os.name == "nt" else "clear") + print() @contextmanager diff --git a/tests/test_cli.py b/tests/test_cli.py index dd79b490..5ce16718 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -107,14 +107,13 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None """One immutable matrix owns profile, scheduling, MODS, and performance behavior.""" parser = cli_methods.parser() expected = { - "standard": ("standard", 1, False, False, False), - "balanced": ("standard", 4, False, False, False), - "quick": ("minimal", 4, False, False, False), - "thorough": ("deep", 4, False, False, False), - "offline": ("offline", 4, False, False, False), - "extensions": ("standard", 4, True, False, False), - "non-python": ("standard", 4, True, True, False), - "performance": ("standard", 1, False, False, True), + "standard": ("standard", 1, False, False), + "balanced": ("standard", 4, False, False), + "quick": ("minimal", 4, False, False), + "thorough": ("deep", 4, False, False), + "offline": ("offline", 4, False, False), + "extensions": ("standard", 4, True, False), + "performance": ("standard", 1, False, True), } self.assertEqual(set(expected), set(EXECUTION_MODES)) @@ -140,7 +139,6 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None run_request.profile, run_request.max_workers, run_request.enable_mods, - run_request.non_python_only, run_request.performance_check, ), ) @@ -151,7 +149,6 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None "minimal": "--minimal", "depth": "--depth", "modded": "--modded", - "nopy": "--nopy", "performance_check": "--performance-check", } diff --git a/tests/test_flow_matrix.py b/tests/test_flow_matrix.py index 8c66db38..7e5409b6 100644 --- a/tests/test_flow_matrix.py +++ b/tests/test_flow_matrix.py @@ -24,7 +24,6 @@ def test_every_required_flow_has_executable_regression_evidence(self) -> None: "threaded": "test_explicit_execution_modes_control_isolated_worker_overlap", "minimal": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", "deep": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", - "non-python": "test_nopy_and_modded_modes_select_declared_mod_types_without_helpers", "performance": "test_performance_report_is_finalized_before_automatic_packaging", "modded": "test_mods_require_sidecars_and_run_as_isolated_registered_artifacts", "debug": "test_dev_writes_explicit_manifest_and_debug_persists_diagnostics", diff --git a/tests/test_mods.py b/tests/test_mods.py index a740ebc2..f3734ea1 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -1,8 +1,6 @@ from __future__ import annotations import json -import os -import shutil import tempfile import unittest import zipfile @@ -222,10 +220,8 @@ def test_python_mod_cannot_mutate_project_configuration_without_write_approval( configuration_path.read_text(encoding="utf-8"), ) - def test_native_mod_requires_explicit_filesystem_write_declaration( - self, - ) -> None: - """Native child processes are quarantined unless their unconfined write risk is declared.""" + def test_non_python_mod_files_are_rejected_before_sidecar_processing(self) -> None: + """MODS rejects former native script formats before metadata can enable them.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) mods = root / "MODS" @@ -237,7 +233,7 @@ def test_native_mod_requires_explicit_filesystem_write_declaration( encoding="utf-8", ) script.with_suffix(".bat.mod.json").write_text( - json.dumps(mod_metadata("native")), + json.dumps(mod_metadata("native", filesystem_write=True)), encoding="utf-8", ) @@ -245,259 +241,46 @@ def test_native_mod_requires_explicit_filesystem_write_declaration( self.assertEqual(1, len(report.invalid)) self.assertIn( - "filesystem_write", - report.invalid[0].runtime_error or "", - ) - - script.with_suffix(".bat.mod.json").write_text( - json.dumps( - mod_metadata( - "native", - filesystem_write=True, - ) - ), - encoding="utf-8", + "MODS supports Python (.py) scripts only", + report.invalid[0].static_errors, ) - report = preflight(root) - - self.assertEqual( - 1, - len(report.valid), - report.invalid, - ) - - plan = build_plan(report, RunRequest(enable_mods=True)) - self.assertEqual(("mod.native",), tuple( - item.metadata.id - for item in plan.collectors - if item.metadata is not None - )) - - def test_nopy_and_modded_modes_select_declared_mod_types_without_helpers( - self, - ) -> None: - """Compatibility modes include all MODS or only non-Python MODS deterministically.""" + def test_extensions_mode_selects_python_mods_and_rejects_nopy(self) -> None: + """The extension mode accepts Python MODS only and has no non-Python alias.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) mods = root / "MODS" mods.mkdir() - for name, extension in ( - ("python_mod", ".py"), - ("batch_mod", ".bat"), - ): - script = mods / f"{name}{extension}" - script.write_text( - ( - "pass\n" - if extension == ".py" - else "@echo off\n" - ), - encoding="utf-8", - ) - script.with_suffix( - extension + ".mod.json" - ).write_text( - json.dumps( - mod_metadata( - name, - filesystem_write=extension != ".py", - ) - ), - encoding="utf-8", - ) - - report = preflight(root) - - self.assertEqual( - 2, - len(report.valid), - report.invalid, + script = mods / "python_mod.py" + script.write_text("pass\n", encoding="utf-8") + script.with_suffix(".py.mod.json").write_text( + json.dumps(mod_metadata("python_mod")), + encoding="utf-8", ) - modded = build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=( - Capability.SUBPROCESS, - Capability.FILESYSTEM_WRITE, - ), - ), - ) + report = preflight(root) + self.assertEqual(1, len(report.valid), report.invalid) - nopy = build_plan( + extensions = build_plan( report, RunRequest( enable_mods=True, - non_python_only=True, - approved_capabilities=( - Capability.SUBPROCESS, - Capability.FILESYSTEM_WRITE, - ), ), ) - - modded_ids: list[str] = [] - for item in modded.collectors: - metadata = item.metadata - if metadata is None: - self.fail( - "planned MOD collector unexpectedly has no metadata" - ) - modded_ids.append(metadata.id) - - nopy_ids: list[str] = [] - for item in nopy.collectors: - metadata = item.metadata - if metadata is None: - self.fail( - "planned non-Python MOD collector unexpectedly has no metadata" - ) - nopy_ids.append(metadata.id) - self.assertEqual( - ["mod.batch_mod", "mod.python_mod"], - modded_ids, - ) - self.assertEqual( - ["mod.batch_mod"], - nopy_ids, + ("mod.python_mod",), + tuple(item.metadata.id for item in extensions.collectors if item.metadata is not None), ) parser = cli_methods.parser() - modded_request = cli_methods.request( parser.parse_args(["run", "--modded"]), 2, ) self.assertTrue(modded_request.enable_mods) - - nopy_request = cli_methods.request( - parser.parse_args(["run", "--nopy"]), - 2, - ) - self.assertTrue(nopy_request.enable_mods) - self.assertTrue(nopy_request.non_python_only) - - @unittest.skipUnless( - os.name == "nt", - "legacy script adapters require Windows", - ) - def test_non_python_mod_adapters_execute_powershell_batch_and_executable_files( - self, - ) -> None: - """Every documented non-Python MODS type executes through its explicit adapter.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - - scripts = { - "powershell_mod.ps1": ( - "Set-Content -LiteralPath report.txt " - "-Value 'powershell evidence'\n" - ), - "batch_mod.bat": ( - "@echo off\r\n" - "echo batch evidence>report.txt\r\n" - ), - } - - for filename, contents in scripts.items(): - script = mods / filename - script.write_text( - contents, - encoding="utf-8", - ) - script.with_suffix( - script.suffix + ".mod.json" - ).write_text( - json.dumps( - mod_metadata( - script.stem, - filesystem_write=True, - ) - ), - encoding="utf-8", - ) - - executable = ( - Path( - os.environ.get( - "WINDIR", - r"C:\Windows", - ) - ) - / "System32" - / "whoami.exe" - ) - - self.assertTrue(executable.is_file()) - - copied_executable = ( - mods / "identity_mod.exe" - ) - shutil.copy2( - executable, - copied_executable, - ) - - copied_executable.with_suffix( - ".exe.mod.json" - ).write_text( - json.dumps( - mod_metadata( - "identity_mod", - filesystem_write=True, - ) - ), - encoding="utf-8", - ) - - report = preflight(root) - - self.assertEqual( - 3, - len(report.valid), - report.invalid, - ) - - plan = build_plan( - report, - RunRequest( - enable_mods=True, - non_python_only=True, - approved_capabilities=( - Capability.SUBPROCESS, - Capability.FILESYSTEM_WRITE, - ), - acknowledge_authorization=True, - max_workers=1, - ), - ) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - self.assertEqual( - [ - "succeeded", - "succeeded", - "succeeded", - ], - [ - record.status - for record in outcome.manifest.collectors - ], - [ - record.errors - for record in outcome.manifest.collectors - ], - ) + with self.assertRaises(SystemExit): + parser.parse_args(["run", "--nopy"]) if __name__ == "__main__": From d8be0e335f7435dacadc9b43c13d4d340c941836 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 14:19:56 +0300 Subject: [PATCH 267/316] feat(console): structure lifecycle output into titled steps Render a startup banner, ASCII phase separators, and consistently marked detail rows through the shared console presentation layer. --- docs/CONFIGURATION.md | 2 +- logicytics/cli/commands.py | 2 +- logicytics/module/logging.py | 50 +++++++++++++++++++++++++++++-- logicytics/module/presentation.py | 38 +++++++++++++++++++++-- logicytics/terminal.py | 4 ++- tests/test_logging.py | 37 +++++++++++++++++++++-- tests/test_terminal.py | 6 ++++ 7 files changed, 129 insertions(+), 10 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ca8bec3c..eb8bb415 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -17,7 +17,7 @@ The root fields are `schema_version`, `runtime`, `interaction`, `maintenance`, ` `runtime.output_root` and `maintenance.local_manifest_path` must stay inside the project when relative. Worker, output, log, and retention values are bounded by the parser. `runtime.blocked_capabilities` is a mapping of capability names to booleans; true values disable matching metadata requests for the run. Remote manifests require a matching HTTPS URL and lowercase SHA-256 digest. Sysinternals is enabled by default; set `maintenance.sysinternals_enabled: false` to disable discovery, download, and extraction. -Application events use the same redacted data for both sinks. The console follows AIBrain's compact, colored status style with severity markers and terminal-width word wrapping; machine-oriented lifecycle names such as `collector_finished` are shown as readable text such as `Collector finished`. INFO text is white, DEBUG text is gray, and the severity marker remains color-coded. File rows include millisecond timestamps, keep the stable aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and render fields as readable continuation lines beneath the message column. JSON-looking messages, mappings, sets, and binary values are normalized before human presentation. Command lifecycle events include terminal status and elapsed time, while run and collector events add worker, retry, artifact, and resource context. Console-only command results are rendered as plain line-by-line output and do not pollute the application event log. The run and collector JSONL files remain intentionally machine-readable and are the only JSON logging sink. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. +Application events use the same redacted data for both sinks. The interactive console begins with a Logicytics banner after clearing the screen, then groups lifecycle events into titled ASCII steps. Severity rows use colored status markers, while each structured detail begins with `>` and wraps beneath that detail. Machine-oriented lifecycle names such as `collector_finished` are shown as readable text such as `Collector finished`. INFO text is white, DEBUG text is gray, and the severity marker remains color-coded. File rows include millisecond timestamps, keep the stable aligned `LOCAL DATE AND TIME | SEVERITY | SOURCE | MESSAGE` layout, wrap at 140 columns, and render fields as readable continuation lines beneath the message column. JSON-looking messages, mappings, sets, and binary values are normalized before human presentation. Command lifecycle events include terminal status and elapsed time, while run and collector events add worker, retry, artifact, and resource context. Console-only command results use the same titled presentation and do not pollute the application event log. The run and collector JSONL files remain intentionally machine-readable and are the only JSON logging sink. `logging.console_enabled`, `logging.color_enabled`, and `logging.file_enabled` control those sinks independently. ## Collector settings diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index ac1a8370..dc98eb54 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -63,7 +63,7 @@ def render_preflight( invalid = validation["invalid"] quarantined = validation["quarantined"] logger.box( - "Preflight", + "Preflight summary", ( f"Valid collectors: {len(valid)}", f"Quarantined extensions: {len(quarantined)}", diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 77fbf1a2..9925b209 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -21,6 +21,7 @@ console_width as presentation_console_width, render_alert as render_presentation_alert, render_section as render_presentation_section, + render_step_heading as render_presentation_step_heading, ) from logicytics.module.redaction import redact_mapping, redact_text @@ -82,6 +83,7 @@ def __init__( self.settings = settings self.console = sys.stderr if console is None else console self._lock = RLock() + self._last_console_step: str | None = None self.path.parent.mkdir(parents=True, exist_ok=True) self._prepare_file() @@ -211,13 +213,51 @@ def _message_lines(cls, message: str) -> tuple[str, ...]: def _console_fields(cls, fields: Mapping[str, object]) -> tuple[str, ...]: """Render structured fields as readable labels instead of JSON fragments.""" rows: list[str] = [] - for key, value in sorted(fields.items()): + for key, value in fields.items(): label = key.replace("_", " ").capitalize() - rendered = cls._console_value(value).splitlines() or ["none"] - rows.append(f"{label}: {rendered[0]}") + rendered_value = cls._console_value(value) + if ( + key in {"configuration_hash", "fingerprint"} + and isinstance(value, str) + and len(rendered_value) > 28 + ): + rendered_value = f"{rendered_value[:12]}...{rendered_value[-12:]}" + rendered = rendered_value.splitlines() or ["none"] + rows.append(f"> {label}: {rendered[0]}") rows.extend(f" {line}" for line in rendered[1:]) return tuple(rows) + @staticmethod + def _lifecycle_step(message: str) -> str | None: + """Map lifecycle events to the console phase that owns their details.""" + if not _EVENT_NAME.fullmatch(message): + return None + if message.startswith("run_packag") or message.startswith("package_"): + return "Packaging" + prefix = message.partition("_")[0] + return { + "command": "Command", + "preflight": "Preflight", + "plan": "Planning", + "run": "Collection", + "collector": "Collector execution", + "package": "Packaging", + }.get(prefix) + + def _render_step(self, message: str) -> None: + """Insert a visual break when lifecycle output moves to a new phase.""" + step = self._lifecycle_step(message) + if step is None or step == self._last_console_step: + return + if self._last_console_step is not None: + self.console.write("\n") + render_presentation_step_heading( + self.console, + step, + width=self._console_width, + ) + self._last_console_step = step + @classmethod def _console_value(cls, value: object) -> str: """Normalize common structured values for compact, human-readable console output.""" @@ -280,6 +320,7 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: stream.write("\n".join(rows) + "\n") self._truncate_file() if self.settings.console_enabled: + self._render_step(message) marker, marker_color, text_color = _LEVEL_PRESENTATION[normalized] if not self._supports_unicode(): marker = {"\u25cf": "*", "\u00d7": "X", "\u00b7": "."}.get(marker, marker) @@ -349,7 +390,10 @@ def box(self, title: str, lines: Iterable[str]) -> None: """Render console-only output as plain redacted lines.""" with self._lock: if self.settings.console_enabled: + if self._last_console_step is not None: + self.console.write("\n") self.render_section(self.console, title, lines) + self._last_console_step = title def dispatch(self, messages: Iterable[str]) -> None: """Parse and dispatch a batch of optional `LEVEL: message` rows.""" diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index 82d141fc..4b5ab975 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -29,6 +29,40 @@ def _plain_message_lines(message: str) -> tuple[str, ...]: return (redact_text(message),) +def _heading(title: str, *, width: ConsoleWidth = console_width) -> str: + """Return an ASCII section heading that remains stable across terminals.""" + safe_title = redact_text(title).strip() or "Logicytics" + available = max(width(), _MIN_CONSOLE_WIDTH) + return f"[ {safe_title} ]\n" + "-" * available + + +def render_banner(console: TextIO, *, width: ConsoleWidth = console_width) -> None: + """Render the compact startup banner after a real terminal clear.""" + available = min(max(width(), _MIN_CONSOLE_WIDTH), 88) + title = " LOGICYTICS " + subtitle = "Local evidence collection framework" + rows = ( + "+" + "-" * available + "+", + "|" + title.center(available) + "|", + "|" + subtitle.center(available) + "|", + "+" + "-" * available + "+", + "", + ) + console.write("\n".join(rows)) + console.flush() + + +def render_step_heading( + console: TextIO, + title: str, + *, + width: ConsoleWidth = console_width, +) -> None: + """Separate a lifecycle phase with a titled ASCII rule.""" + console.write(f"{_heading(title, width=width)}\n") + console.flush() + + def _alert_symbols(console: TextIO) -> tuple[str, str, str, str, str, str, str, str]: """Use Unicode alert framing when the destination can encode it.""" encoding = getattr(console, "encoding", None) or "utf-8" @@ -49,13 +83,13 @@ def render_section( width: ConsoleWidth = console_width, ) -> None: """Render the shared indented, wrapped console section presentation.""" - rows = list(message_lines(title)) + rows = [_heading(title, width=width)] for line in lines: for raw_row in line.splitlines() or [""]: safe_row = redact_text(raw_row) indentation = safe_row[:len(safe_row) - len(safe_row.lstrip())] remaining = safe_row.lstrip().rstrip() - prefix = f" {indentation}" + prefix = f" > {indentation}" continuation_prefix = f" {indentation}" available = max(width() - len(continuation_prefix), 1) presentation_rows = message_lines(remaining) diff --git a/logicytics/terminal.py b/logicytics/terminal.py index 55ad53c0..17a65bd5 100644 --- a/logicytics/terminal.py +++ b/logicytics/terminal.py @@ -7,6 +7,8 @@ from collections.abc import Iterator from contextlib import contextmanager +from logicytics.module.presentation import render_banner + _ACTIVE_SESSIONS = 0 @@ -18,7 +20,7 @@ def _is_interactive_terminal() -> bool: def _clear_terminal() -> None: """Clear the shared terminal screen through the platform's real console command.""" os.system("cls" if os.name == "nt" else "clear") - print() + render_banner(sys.stderr) @contextmanager diff --git a/tests/test_logging.py b/tests/test_logging.py index e726752a..69bbef95 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -223,7 +223,14 @@ def isatty(self) -> bool: rows = console.getvalue().splitlines() self.assertTrue(rows[0].startswith(" * ")) self.assertTrue(rows[1].startswith(" ")) - self.assertEqual(["Summary", " finished"], rows[-2:]) + self.assertEqual( + [ + "[ Summary ]", + "-" * ApplicationLogger._console_width(), + " > finished", + ], + rows[-3:], + ) self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) @@ -276,10 +283,36 @@ def test_application_logging_wraps_long_plain_sections(self) -> None: logger.box("Summary", ("detail " * 40,)) rows = console.getvalue().splitlines() - self.assertEqual("Summary", rows[0]) + self.assertEqual("[ Summary ]", rows[0]) + self.assertEqual("-" * ApplicationLogger._console_width(), rows[1]) self.assertGreater(len(rows), 2) self.assertTrue(all(len(row) <= ApplicationLogger._console_width() for row in rows)) + def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> None: + """Lifecycle output has titled ASCII steps and consistently marked details.""" + with tempfile.TemporaryDirectory() as temporary: + console = io.StringIO() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(file_enabled=False, color_enabled=False), + console=console, + ) + logger.event("INFO", "command_started", command="preflight") + logger.event("INFO", "preflight_started", configuration_hash="a" * 64) + logger.event("INFO", "run_packaging_started", run_id="run-test") + logger.event("INFO", "run_packaged", run_id="run-test") + + rendered = console.getvalue() + self.assertIn("[ Command ]\n", rendered) + self.assertIn("[ Preflight ]\n", rendered) + self.assertIn(" ● Command started\n > Command: preflight", rendered) + self.assertIn( + " ● Preflight started\n > Configuration hash: " + "aaaaaaaaaaaa...aaaaaaaaaaaa", + rendered, + ) + self.assertEqual(1, rendered.count("[ Packaging ]")) + def test_application_logging_colors_info_text_white_and_debug_text_gray(self) -> None: """Status markers retain their severity color while message text stays readable.""" diff --git a/tests/test_terminal.py b/tests/test_terminal.py index a5bb65a3..9a75b582 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -23,7 +23,12 @@ class TerminalLifecycleTests(unittest.TestCase): def test_windows_clear_uses_the_real_console_command(self) -> None: """Windows startup clears the shared screen buffer through cls, not escape bytes.""" output = _InteractiveBuffer() + errors = _InteractiveBuffer() with patch.object(terminal.sys, "stdout", output), patch.object( + terminal.sys, + "stderr", + errors, + ), patch.object( terminal.os, "name", "nt", @@ -36,6 +41,7 @@ def test_windows_clear_uses_the_real_console_command(self) -> None: system.assert_called_once_with("cls") self.assertEqual("", output.getvalue()) + self.assertIn("LOGICYTICS", errors.getvalue()) def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None: """The outer lifecycle performs real clearing once and leaves a clean prompt line.""" From ab1af8fea5aa923a7b72c0ba53cb5039862ba1bc Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 14:24:42 +0300 Subject: [PATCH 268/316] fix(console): compact normal configuration hash output Show seven configuration-hash characters in normal console output while retaining the full value in debug output and file logs. --- logicytics/module/logging.py | 22 +++++++++++++-------- tests/test_logging.py | 38 ++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 9925b209..d8b3ba87 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -210,18 +210,19 @@ def _message_lines(cls, message: str) -> tuple[str, ...]: return (cls._console_message(safe_message),) @classmethod - def _console_fields(cls, fields: Mapping[str, object]) -> tuple[str, ...]: + def _console_fields( + cls, + fields: Mapping[str, object], + *, + compact_configuration_hash: bool = False, + ) -> tuple[str, ...]: """Render structured fields as readable labels instead of JSON fragments.""" rows: list[str] = [] for key, value in fields.items(): label = key.replace("_", " ").capitalize() rendered_value = cls._console_value(value) - if ( - key in {"configuration_hash", "fingerprint"} - and isinstance(value, str) - and len(rendered_value) > 28 - ): - rendered_value = f"{rendered_value[:12]}...{rendered_value[-12:]}" + if compact_configuration_hash and key == "configuration_hash": + rendered_value = rendered_value[:7] rendered = rendered_value.splitlines() or ["none"] rows.append(f"> {label}: {rendered[0]}") rows.extend(f" {line}" for line in rendered[1:]) @@ -311,7 +312,12 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: file_lines.extend(self._console_fields(safe_fields)) rendered_message = "\n".join(file_lines) console_lines = list(self._message_lines(safe_message)) - console_lines.extend(self._console_fields(safe_fields)) + console_lines.extend( + self._console_fields( + safe_fields, + compact_configuration_hash=minimum_level != "DEBUG", + ) + ) console_message = "\n".join(console_lines) rows = self._rows(normalized, source, rendered_message) with self._lock: diff --git a/tests/test_logging.py b/tests/test_logging.py index 69bbef95..62249967 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -307,12 +307,46 @@ def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> self.assertIn("[ Preflight ]\n", rendered) self.assertIn(" ● Command started\n > Command: preflight", rendered) self.assertIn( - " ● Preflight started\n > Configuration hash: " - "aaaaaaaaaaaa...aaaaaaaaaaaa", + " ● Preflight started\n > Configuration hash: aaaaaaa", rendered, ) self.assertEqual(1, rendered.count("[ Packaging ]")) + def test_application_logging_compacts_configuration_hash_only_outside_debug(self) -> None: + """Normal console output stays concise without losing the logged hash.""" + configuration_hash = "0123456789abcdef" * 4 + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "Logicytics.log" + normal_console = io.StringIO() + normal_logger = ApplicationLogger( + path, + LoggingSettings(color_enabled=False), + console=normal_console, + ) + normal_logger.event( + "INFO", + "preflight_started", + configuration_hash=configuration_hash, + ) + + self.assertIn("> Configuration hash: 0123456", normal_console.getvalue()) + self.assertNotIn(configuration_hash, normal_console.getvalue()) + self.assertIn(configuration_hash, path.read_text(encoding="utf-8")) + + debug_console = io.StringIO() + debug_logger = ApplicationLogger( + path, + LoggingSettings(level="DEBUG", file_enabled=False, color_enabled=False), + console=debug_console, + ) + debug_logger.event( + "INFO", + "preflight_started", + configuration_hash=configuration_hash, + ) + + self.assertIn(configuration_hash, debug_console.getvalue()) + def test_application_logging_colors_info_text_white_and_debug_text_gray(self) -> None: """Status markers retain their severity color while message text stays readable.""" From 8e256f760bf92f3e9bc915614b2a5a08ea5711b0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 14:30:36 +0300 Subject: [PATCH 269/316] feat(console): refine boxed lifecycle presentation Replace raw divider rules with bounded ASCII title boxes, add spacing between lifecycle phases, and render detail markers purple on colored consoles. --- logicytics/module/logging.py | 32 +++++++++++++++++++++++--- logicytics/module/presentation.py | 31 +++++++++++++++++++------ tests/test_logging.py | 38 +++++++++++++++++++------------ 3 files changed, 77 insertions(+), 24 deletions(-) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index d8b3ba87..fe7ba92f 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -45,6 +45,7 @@ "EXCEPTION": ("\u00d7", "\033[91m", "\033[91m"), "CRITICAL": ("\u00d7", "\033[91m", "\033[91m"), } +_DETAIL_MARKER_COLOR = "\033[95m" _RESET = "\033[0m" _BOLD = "\033[1m" _FILE_LOG_LINE_WIDTH = 140 @@ -179,6 +180,19 @@ def _console_rows(cls, marker: str, message: str) -> tuple[str, ...]: rows.append(current_prefix + remaining) return tuple(rows) + @staticmethod + def _color_console_text(row: str, text_color: str) -> str: + """Color a leading detail marker purple while retaining the event color.""" + indentation_length = len(row) - len(row.lstrip()) + detail = row[indentation_length:] + if detail.startswith("> "): + return ( + f"{text_color}{_BOLD}{row[:indentation_length]}" + f"{_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET}" + f"{text_color}{_BOLD}{detail[1:]}" + ) + return f"{text_color}{_BOLD}{row}" + @staticmethod def _console_message(message: str) -> str: """Make machine-oriented lifecycle event names readable on the console.""" @@ -333,13 +347,18 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: console_rows = self._console_rows(marker, console_message) if self.settings.color_enabled and self.console.isatty(): marker_prefix = f" {marker} " + first_row = self._color_console_text( + console_rows[0][len(marker_prefix):], + text_color, + ) colored_rows = ( f"{marker_color}{_BOLD}{marker_prefix}{_RESET}" - f"{text_color}{_BOLD}{console_rows[0][len(marker_prefix):]}" + f"{first_row}" ) if len(console_rows) > 1: colored_rows += "\n" + "\n".join( - f"{text_color}{_BOLD}{row}" for row in console_rows[1:] + self._color_console_text(row, text_color) + for row in console_rows[1:] ) self.console.write(f"{colored_rows}{_RESET}\n") else: @@ -398,7 +417,14 @@ def box(self, title: str, lines: Iterable[str]) -> None: if self.settings.console_enabled: if self._last_console_step is not None: self.console.write("\n") - self.render_section(self.console, title, lines) + render_presentation_section( + self.console, + title, + lines, + message_lines=self._message_lines, + width=self._console_width, + color_enabled=self.settings.color_enabled, + ) self._last_console_step = title def dispatch(self, messages: Iterable[str]) -> None: diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index 4b5ab975..c22cc6a9 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -13,6 +13,9 @@ _DEFAULT_CONSOLE_WIDTH = 82 _MIN_CONSOLE_WIDTH = 60 _RIGHT_EDGE_MARGIN = 4 +_DETAIL_MARKER_COLOR = "\033[95m" +_BOLD = "\033[1m" +_RESET = "\033[0m" MessageLines = Callable[[str], tuple[str, ...]] ConsoleWidth = Callable[[], int] @@ -30,15 +33,21 @@ def _plain_message_lines(message: str) -> tuple[str, ...]: def _heading(title: str, *, width: ConsoleWidth = console_width) -> str: - """Return an ASCII section heading that remains stable across terminals.""" + """Return an ASCII title box that remains stable across terminals.""" safe_title = redact_text(title).strip() or "Logicytics" available = max(width(), _MIN_CONSOLE_WIDTH) - return f"[ {safe_title} ]\n" + "-" * available + inner_width = available - 2 + heading = f" {safe_title} "[:inner_width] + return "\n".join(( + "+" + "-" * inner_width + "+", + "|" + heading.ljust(inner_width) + "|", + "+" + "-" * inner_width + "+", + )) def render_banner(console: TextIO, *, width: ConsoleWidth = console_width) -> None: """Render the compact startup banner after a real terminal clear.""" - available = min(max(width(), _MIN_CONSOLE_WIDTH), 88) + available = min(max(width() - 2, _MIN_CONSOLE_WIDTH - 2), 86) title = " LOGICYTICS " subtitle = "Local evidence collection framework" rows = ( @@ -58,8 +67,8 @@ def render_step_heading( *, width: ConsoleWidth = console_width, ) -> None: - """Separate a lifecycle phase with a titled ASCII rule.""" - console.write(f"{_heading(title, width=width)}\n") + """Separate a lifecycle phase with a titled ASCII box and breathing room.""" + console.write(f"{_heading(title, width=width)}\n\n") console.flush() @@ -81,9 +90,11 @@ def render_section( *, message_lines: MessageLines = _plain_message_lines, width: ConsoleWidth = console_width, + color_enabled: bool = False, ) -> None: """Render the shared indented, wrapped console section presentation.""" - rows = [_heading(title, width=width)] + rows = [_heading(title, width=width), ""] + use_color = color_enabled and console.isatty() for line in lines: for raw_row in line.splitlines() or [""]: safe_row = redact_text(raw_row) @@ -101,7 +112,13 @@ def render_section( break_on_hyphens=False, ) or [""] row_prefix = prefix if presentation_index == 0 else continuation_prefix - rows.append(row_prefix + wrapped[0]) + first_row = row_prefix + wrapped[0] + if use_color and presentation_index == 0: + first_row = ( + f"{prefix[:-2]}{_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET} " + f"{indentation}{wrapped[0]}" + ) + rows.append(first_row) rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) console.write("\n".join(rows) + "\n") console.flush() diff --git a/tests/test_logging.py b/tests/test_logging.py index 62249967..d84da9cb 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -223,14 +223,13 @@ def isatty(self) -> bool: rows = console.getvalue().splitlines() self.assertTrue(rows[0].startswith(" * ")) self.assertTrue(rows[1].startswith(" ")) - self.assertEqual( - [ - "[ Summary ]", - "-" * ApplicationLogger._console_width(), - " > finished", - ], - rows[-3:], - ) + border = "+" + "-" * (ApplicationLogger._console_width() - 2) + "+" + summary_start = rows.index(border, 2) + self.assertEqual(border, rows[summary_start]) + self.assertTrue(rows[summary_start + 1].startswith("| Summary ")) + self.assertEqual(border, rows[summary_start + 2]) + self.assertEqual("", rows[summary_start + 3]) + self.assertEqual(" > finished", rows[summary_start + 4]) self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) @@ -283,9 +282,12 @@ def test_application_logging_wraps_long_plain_sections(self) -> None: logger.box("Summary", ("detail " * 40,)) rows = console.getvalue().splitlines() - self.assertEqual("[ Summary ]", rows[0]) - self.assertEqual("-" * ApplicationLogger._console_width(), rows[1]) - self.assertGreater(len(rows), 2) + border = "+" + "-" * (ApplicationLogger._console_width() - 2) + "+" + self.assertEqual(border, rows[0]) + self.assertTrue(rows[1].startswith("| Summary ")) + self.assertEqual(border, rows[2]) + self.assertEqual("", rows[3]) + self.assertGreater(len(rows), 4) self.assertTrue(all(len(row) <= ApplicationLogger._console_width() for row in rows)) def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> None: @@ -303,14 +305,16 @@ def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> logger.event("INFO", "run_packaged", run_id="run-test") rendered = console.getvalue() - self.assertIn("[ Command ]\n", rendered) - self.assertIn("[ Preflight ]\n", rendered) + border = "+" + "-" * (ApplicationLogger._console_width() - 2) + "+" + self.assertIn(f"{border}\n| Command ", rendered) + self.assertIn(f"{border}\n| Preflight ", rendered) + self.assertIn(f"{border}\n\n ● Command started", rendered) self.assertIn(" ● Command started\n > Command: preflight", rendered) self.assertIn( " ● Preflight started\n > Configuration hash: aaaaaaa", rendered, ) - self.assertEqual(1, rendered.count("[ Packaging ]")) + self.assertEqual(1, rendered.count("| Packaging ")) def test_application_logging_compacts_configuration_hash_only_outside_debug(self) -> None: """Normal console output stays concise without losing the logged hash.""" @@ -364,6 +368,7 @@ def isatty(self) -> bool: logger.event("INFO", "info message") logger.event("DEBUG", "debug message") logger.event("INFO", "collector_finished", collector_id="core.system.system_info") + logger.box("Summary", ("finished",)) output = console.getvalue() self.assertIn( @@ -376,6 +381,11 @@ def isatty(self) -> bool: ) self.assertIn("Collector finished\n", output) self.assertIn("Collector id: core.system.system_info", output) + self.assertIn( + "\033[95m\033[1m>\033[0m\033[97m\033[1m Collector id:", + output, + ) + self.assertIn("\033[95m\033[1m>\033[0m finished", output) self.assertNotIn("collector_id=", output) def test_deprecation_decorator_logs_removal_context(self) -> None: From e2b87de74ffc967336fecc945d9aad6c4cbc3567 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 14:35:37 +0300 Subject: [PATCH 270/316] fix(console): use full-width ASCII headings Render the startup banner with strict ASCII borders across the physical terminal width and simplify lifecycle headings to title-plus-rule separators. --- logicytics/module/presentation.py | 38 +++++++++++++------------------ tests/test_logging.py | 37 +++++++++++++----------------- tests/test_terminal.py | 12 ++++++++++ 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index c22cc6a9..ae502b1a 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -21,6 +21,12 @@ ConsoleWidth = Callable[[], int] +def terminal_width() -> int: + """Return the full physical terminal width for full-span presentation.""" + width = shutil.get_terminal_size((_DEFAULT_CONSOLE_WIDTH, 24)).columns + return max(width, _MIN_CONSOLE_WIDTH) + + def console_width() -> int: """Return the shared terminal width with a safety margin and minimum.""" width = shutil.get_terminal_size((_DEFAULT_CONSOLE_WIDTH, 24)).columns @@ -33,21 +39,15 @@ def _plain_message_lines(message: str) -> tuple[str, ...]: def _heading(title: str, *, width: ConsoleWidth = console_width) -> str: - """Return an ASCII title box that remains stable across terminals.""" + """Return a title followed by a full-width ASCII rule.""" safe_title = redact_text(title).strip() or "Logicytics" available = max(width(), _MIN_CONSOLE_WIDTH) - inner_width = available - 2 - heading = f" {safe_title} "[:inner_width] - return "\n".join(( - "+" + "-" * inner_width + "+", - "|" + heading.ljust(inner_width) + "|", - "+" + "-" * inner_width + "+", - )) + return f"{safe_title}\n" + "-" * available -def render_banner(console: TextIO, *, width: ConsoleWidth = console_width) -> None: - """Render the compact startup banner after a real terminal clear.""" - available = min(max(width() - 2, _MIN_CONSOLE_WIDTH - 2), 86) +def render_banner(console: TextIO, *, width: ConsoleWidth = terminal_width) -> None: + """Render a full-width, strictly ASCII startup banner after a real clear.""" + available = max(width() - 2, _MIN_CONSOLE_WIDTH - 2) title = " LOGICYTICS " subtitle = "Local evidence collection framework" rows = ( @@ -67,20 +67,14 @@ def render_step_heading( *, width: ConsoleWidth = console_width, ) -> None: - """Separate a lifecycle phase with a titled ASCII box and breathing room.""" + """Separate a lifecycle phase with a titled ASCII rule and breathing room.""" console.write(f"{_heading(title, width=width)}\n\n") console.flush() -def _alert_symbols(console: TextIO) -> tuple[str, str, str, str, str, str, str, str]: - """Use Unicode alert framing when the destination can encode it.""" - encoding = getattr(console, "encoding", None) or "utf-8" - symbols = ("╭", "─", "╮", "│", "╰", "╯", "×", "→") - try: - "".join(symbols).encode(encoding) - except (LookupError, UnicodeEncodeError): - return ("+", "-", "+", "|", "+", "+", "!", ">") - return symbols +def _alert_symbols() -> tuple[str, str, str, str, str, str, str, str]: + """Return an unambiguous ASCII alert frame for every terminal.""" + return ("+", "-", "+", "|", "+", "+", "!", ">") def render_section( @@ -142,7 +136,7 @@ def render_alert( bottom_right, error_marker, next_step_marker, - ) = _alert_symbols(console) + ) = _alert_symbols() inner_width = min(max(width(), _MIN_CONSOLE_WIDTH), 88) - 2 safe_title = redact_text(title).strip() heading = f" {safe_title} "[:inner_width] diff --git a/tests/test_logging.py b/tests/test_logging.py index d84da9cb..495ee26f 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -68,9 +68,9 @@ def test_virtual_environment_error_uses_the_logging_alert_formatter(self) -> Non render_virtual_environment_error(actual, root) self.assertEqual(expected.getvalue(), actual.getvalue()) - self.assertIn("╭", actual.getvalue()) - self.assertIn("│ ×", actual.getvalue()) - self.assertIn("│ →", actual.getvalue()) + self.assertIn("+ Logicytics startup error", actual.getvalue()) + self.assertIn("| ! ", actual.getvalue()) + self.assertIn("| > ", actual.getvalue()) def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: """The application sink is typed, redacted, reusable, colored, and size bounded.""" @@ -223,13 +223,10 @@ def isatty(self) -> bool: rows = console.getvalue().splitlines() self.assertTrue(rows[0].startswith(" * ")) self.assertTrue(rows[1].startswith(" ")) - border = "+" + "-" * (ApplicationLogger._console_width() - 2) + "+" - summary_start = rows.index(border, 2) - self.assertEqual(border, rows[summary_start]) - self.assertTrue(rows[summary_start + 1].startswith("| Summary ")) - self.assertEqual(border, rows[summary_start + 2]) - self.assertEqual("", rows[summary_start + 3]) - self.assertEqual(" > finished", rows[summary_start + 4]) + summary_start = rows.index("Summary", 2) + self.assertEqual("-" * ApplicationLogger._console_width(), rows[summary_start + 1]) + self.assertEqual("", rows[summary_start + 2]) + self.assertEqual(" > finished", rows[summary_start + 3]) self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) @@ -282,12 +279,10 @@ def test_application_logging_wraps_long_plain_sections(self) -> None: logger.box("Summary", ("detail " * 40,)) rows = console.getvalue().splitlines() - border = "+" + "-" * (ApplicationLogger._console_width() - 2) + "+" - self.assertEqual(border, rows[0]) - self.assertTrue(rows[1].startswith("| Summary ")) - self.assertEqual(border, rows[2]) - self.assertEqual("", rows[3]) - self.assertGreater(len(rows), 4) + self.assertEqual("Summary", rows[0]) + self.assertEqual("-" * ApplicationLogger._console_width(), rows[1]) + self.assertEqual("", rows[2]) + self.assertGreater(len(rows), 3) self.assertTrue(all(len(row) <= ApplicationLogger._console_width() for row in rows)) def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> None: @@ -305,16 +300,16 @@ def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> logger.event("INFO", "run_packaged", run_id="run-test") rendered = console.getvalue() - border = "+" + "-" * (ApplicationLogger._console_width() - 2) + "+" - self.assertIn(f"{border}\n| Command ", rendered) - self.assertIn(f"{border}\n| Preflight ", rendered) - self.assertIn(f"{border}\n\n ● Command started", rendered) + rule = "-" * ApplicationLogger._console_width() + self.assertIn(f"Command\n{rule}", rendered) + self.assertIn(f"Preflight\n{rule}", rendered) + self.assertIn(f"{rule}\n\n ● Command started", rendered) self.assertIn(" ● Command started\n > Command: preflight", rendered) self.assertIn( " ● Preflight started\n > Configuration hash: aaaaaaa", rendered, ) - self.assertEqual(1, rendered.count("| Packaging ")) + self.assertEqual(1, rendered.count("Packaging\n")) def test_application_logging_compacts_configuration_hash_only_outside_debug(self) -> None: """Normal console output stays concise without losing the logged hash.""" diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 9a75b582..1afdcf7e 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -7,6 +7,7 @@ from unittest.mock import patch from logicytics import terminal +from logicytics.module.presentation import render_banner class _InteractiveBuffer(io.StringIO): @@ -43,6 +44,17 @@ def test_windows_clear_uses_the_real_console_command(self) -> None: self.assertEqual("", output.getvalue()) self.assertIn("LOGICYTICS", errors.getvalue()) + def test_banner_is_full_width_and_strictly_ascii(self) -> None: + """The startup banner spans the terminal without Unicode border glyphs.""" + console = io.StringIO() + render_banner(console, width=lambda: 101) + + rows = console.getvalue().splitlines() + self.assertEqual(101, len(rows[0])) + self.assertEqual("+", rows[0][0]) + self.assertEqual("+", rows[0][-1]) + self.assertTrue(all(ord(character) < 128 for row in rows for character in row)) + def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None: """The outer lifecycle performs real clearing once and leaves a clean prompt line.""" output = _InteractiveBuffer() From 9df0b78d9a2f670bbcafc8940ab0c36de5038b45 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 18:21:11 +0300 Subject: [PATCH 271/316] style(python): resolve PEP lint findings Format the Python codebase, normalize imports, retain Python 3.11 compatibility, and define the repository PEP lint policy. --- core/bluetooth/bluetooth_addresses.py | 71 ++- core/bluetooth/bluetooth_history.py | 66 ++- core/bluetooth/paired_devices.py | 7 +- core/browser/browser_data_backup.py | 88 ++-- core/diagnostics/sysinternals_report.py | 38 +- core/encryption/bitlocker_status.py | 22 +- core/encryption/bitlocker_volumes.py | 31 +- core/event_log/application_events.py | 15 +- core/event_log/security_events.py | 39 +- core/filesystem/sensitive_file_inventory.py | 84 +++- core/filesystem/startup_folder_entries.py | 45 +- core/filesystem/system_drive_listing.py | 47 +- core/filesystem/system_drive_tree.py | 38 +- core/hardware/battery_status.py | 44 +- core/hardware/display_adapters.py | 38 +- core/hardware/windows_features.py | 12 +- core/integration/legacy_code_outputs.py | 62 +-- core/media/media_backup.py | 38 +- core/memory/memory_snapshot.py | 14 +- core/network/active_connections.py | 23 +- core/network/adapter_statistics.py | 23 +- core/network/bandwidth_sample.py | 90 +++- core/network/connection_processes.py | 54 ++- core/network/dns_cache.py | 28 +- core/network/firewall_profiles.py | 48 +- core/network/network_adapters.py | 4 +- core/network/network_identity.py | 4 +- core/network/network_interfaces.py | 35 +- core/packet/connection_graph.py | 35 +- core/packet/packet_capture.py | 48 +- core/process/detailed_processes.py | 23 +- core/process/memory_map.py | 88 ++-- core/process/process_memory.py | 45 +- core/registry/hklm_backup.py | 48 +- core/registry/installed_applications.py | 54 ++- core/registry/startup_applications.py | 66 ++- core/ssh/ssh_backup.py | 46 +- core/storage/logical_drives.py | 27 +- core/storage/mounted_volumes.py | 19 +- core/storage/physical_disks.py | 23 +- core/storage/volume_details.py | 87 ++-- core/system/bios_info.py | 2 +- core/system/defender_status.py | 31 +- core/system/environment_posture.py | 43 +- core/system/installed_drivers.py | 7 +- core/system/installed_updates.py | 43 +- core/system/local_accounts.py | 16 +- core/system/scheduled_tasks.py | 24 +- core/system/session_snapshot.py | 23 +- core/system/system_diagnostics.py | 58 ++- core/system/system_info.py | 12 +- core/system/windows_services.py | 44 +- core/system/windows_system_data_backup.py | 44 +- core/system/wmic_inventory.py | 10 +- core/usb/usb_storage_inventory.py | 11 +- core/wireless/wifi_interfaces.py | 41 +- core/wireless/wifi_profile_keys.py | 53 ++- core/wireless/wifi_profiles.py | 19 +- logicytics/__init__.py | 31 +- logicytics/cli/__init__.py | 2 +- logicytics/cli/commands.py | 470 +++++++------------- logicytics/cli/installer.py | 1 - logicytics/cli/tests.py | 3 +- logicytics/contracts.py | 177 ++++---- logicytics/global/ctypes_collector.py | 57 ++- logicytics/module/api.py | 157 +++---- logicytics/module/artifacts.py | 110 ++--- logicytics/module/command_runner.py | 17 +- logicytics/module/configuration.py | 277 ++++++------ logicytics/module/discovery.py | 307 +++++-------- logicytics/module/environment.py | 4 +- logicytics/module/errors.py | 5 +- logicytics/module/interaction.py | 57 +-- logicytics/module/logging.py | 150 +++---- logicytics/module/maintenance.py | 103 ++--- logicytics/module/manifest.py | 62 ++- logicytics/module/mod_runner.py | 5 +- logicytics/module/modes.py | 58 +-- logicytics/module/output_layout.py | 12 +- logicytics/module/packaging.py | 50 +-- logicytics/module/planner.py | 56 +-- logicytics/module/presentation.py | 49 +- logicytics/module/redaction.py | 5 +- logicytics/module/runtime.py | 433 +++++++++--------- logicytics/module/sysinternals.py | 11 +- logicytics/module/validation_worker.py | 76 ++-- logicytics/platform_adapters.py | 78 +--- logicytics/virtual_environment.py | 1 - pyproject.toml | 5 +- tests/fixtures/collectors.py | 26 +- tests/fixtures/file_listing.py | 17 +- tests/test_artifact_writer.py | 8 +- tests/test_cli.py | 169 +++---- tests/test_collector_cancellation.py | 215 +++------ tests/test_configuration.py | 80 ++-- tests/test_configuration_documentation.py | 20 +- tests/test_contracts.py | 61 ++- tests/test_golden_outputs.py | 70 ++- tests/test_interaction.py | 35 +- tests/test_legacy_code_outputs_collector.py | 6 +- tests/test_logging.py | 63 +-- tests/test_maintenance.py | 66 +-- tests/test_mods.py | 50 +-- tests/test_packaging.py | 141 +++--- tests/test_planning.py | 228 ++++++---- tests/test_platform_adapters.py | 149 ++++--- tests/test_preflight.py | 175 +++++--- tests/test_public_api.py | 48 +- tests/test_release_documentation.py | 51 ++- tests/test_running_processes_collector.py | 20 +- tests/test_runtime.py | 216 ++++----- tests/test_scheduler.py | 31 +- tests/test_shipped_collectors.py | 303 ++++--------- tests/test_terminal.py | 37 +- tests/test_test_runner_cli.py | 48 +- tests/test_windows_features_collector.py | 4 +- tests/test_windows_integration.py | 52 ++- tests/test_wmic_inventory_collector.py | 27 +- tests/test_worker_security.py | 132 +++--- 119 files changed, 4049 insertions(+), 3526 deletions(-) diff --git a/core/bluetooth/bluetooth_addresses.py b/core/bluetooth/bluetooth_addresses.py index 377a0162..65203247 100644 --- a/core/bluetooth/bluetooth_addresses.py +++ b/core/bluetooth/bluetooth_addresses.py @@ -5,7 +5,14 @@ import json import re -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -27,14 +34,7 @@ def _format_address(instance_id: str | None) -> str | None: instance_id, ) - return ( - ":".join( - match.group(1)[offset:offset + 2].upper() - for offset in range(0, 12, 2) - ) - if match - else None - ) + return ":".join(match.group(1)[offset : offset + 2].upper() for offset in range(0, 12, 2)) if match else None class BluetoothAddressesCollector(CoreCollector): @@ -44,14 +44,19 @@ class BluetoothAddressesCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Bluetooth-address JSON artifact contract.""" return CollectorMetadata( - id="core.bluetooth.bluetooth_addresses", name="Bluetooth addresses", version="4.0.0", + id="core.bluetooth.bluetooth_addresses", + name="Bluetooth addresses", + version="4.0.0", specialty=Specialty.BLUETOOTH, output_media_types=("application/json",), description="Exports paired Bluetooth friendly names and address-like identifiers from PnP data.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("wireless_identifiers",), - default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=256 * 1024, + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=256 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -68,30 +73,50 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-address collection") context.report_progress("bluetooth_addresses_started") command = "Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Present | ConvertTo-Json -Depth 2" - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=25) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=25, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "Bluetooth PnP access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Bluetooth PnP access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query failed", errors=(detail,)) try: raw = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "Bluetooth PnP query returned invalid JSON", + errors=(str(error),), + ) devices = raw if isinstance(raw, list) else [raw] if not all(isinstance(device, dict) for device in devices): return CollectorResult(CollectorStatus.FAILED, "Bluetooth PnP query returned an unexpected result") - report = [{"friendly_name": device.get("FriendlyName"), "instance_id": device.get("InstanceId"), - "address": _format_address(device.get("InstanceId")), "present": device.get("Present")} for device in - devices] + report = [ + { + "friendly_name": device.get("FriendlyName"), + "instance_id": device.get("InstanceId"), + "address": _format_address(device.get("InstanceId")), + "present": device.get("Present"), + } + for device in devices + ] output = context.workspace / "bluetooth_addresses.json" output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bluetooth_addresses_finished", device_count=len(report), - bytes_written=artifact.size_bytes) + context.report_progress( + "bluetooth_addresses_finished", + device_count=len(report), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("Bluetooth names and address identifiers collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/bluetooth/bluetooth_history.py b/core/bluetooth/bluetooth_history.py index bdec8961..a2599cda 100644 --- a/core/bluetooth/bluetooth_history.py +++ b/core/bluetooth/bluetooth_history.py @@ -3,9 +3,16 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -24,12 +31,18 @@ class BluetoothHistoryCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the bounded Bluetooth-history JSON artifact contract.""" return CollectorMetadata( - id="core.bluetooth.bluetooth_history", name="Bluetooth history snapshot", version="4.0.0", + id="core.bluetooth.bluetooth_history", + name="Bluetooth history snapshot", + version="4.0.0", specialty=Specialty.BLUETOOTH, output_media_types=("application/json",), description="Exports a timestamped Bluetooth PnP snapshot; retained run packages provide historical evidence.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("device_identifiers",), default_profiles=("deep",), timeout_seconds=45, + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("device_identifiers",), + default_profiles=("deep",), + timeout_seconds=45, maximum_output_bytes=512 * 1024, ) @@ -46,27 +59,48 @@ def collect(self, context: CollectorContext) -> CollectorResult: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Bluetooth-history collection") context.report_progress("bluetooth_history_started") - command = "$ErrorActionPreference = 'Stop'; @(Get-PnpDevice -Class Bluetooth | Select-Object FriendlyName, InstanceId, Status, Problem, Present) | ConvertTo-Json -Depth 3" - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=40) + command = ( + "$ErrorActionPreference = 'Stop'; " + "@(Get-PnpDevice -Class Bluetooth | " + "Select-Object FriendlyName, InstanceId, Status, Problem, Present) | " + "ConvertTo-Json -Depth 3" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "Bluetooth-history access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Bluetooth-history access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query failed", errors=(detail,)) try: devices = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Bluetooth-history query returned invalid JSON", - errors=(str(error),)) - snapshot = {"collected_at": datetime.now(timezone.utc).isoformat(), - "devices": devices if isinstance(devices, list) else [devices]} + return CollectorResult( + CollectorStatus.FAILED, + "Bluetooth-history query returned invalid JSON", + errors=(str(error),), + ) + snapshot = { + "collected_at": datetime.now(UTC).isoformat(), + "devices": devices if isinstance(devices, list) else [devices], + } output = context.workspace / "bluetooth_history.json" output.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bluetooth_history_finished", device_count=len(snapshot["devices"]), - bytes_written=artifact.size_bytes) + context.report_progress( + "bluetooth_history_finished", + device_count=len(snapshot["devices"]), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("Bluetooth history snapshot collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/bluetooth/paired_devices.py b/core/bluetooth/paired_devices.py index ef547257..eb8ce941 100644 --- a/core/bluetooth/paired_devices.py +++ b/core/bluetooth/paired_devices.py @@ -90,8 +90,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "bluetooth_devices.json" output.write_text(json.dumps(devices, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bluetooth_devices_finished", device_count=len(devices), - bytes_written=artifact.size_bytes) + context.report_progress( + "bluetooth_devices_finished", + device_count=len(devices), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("Bluetooth device metadata collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/browser/browser_data_backup.py b/core/browser/browser_data_backup.py index 7a31aadc..af0d47b9 100644 --- a/core/browser/browser_data_backup.py +++ b/core/browser/browser_data_backup.py @@ -4,13 +4,28 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter -CHROMIUM_FILES = ("History", "History-journal", "Cookies", "Cookies-journal", "Login Data", "Login Data-journal", - "Bookmarks", "Preferences") +CHROMIUM_FILES = ( + "History", + "History-journal", + "Cookies", + "Cookies-journal", + "Login Data", + "Login Data-journal", + "Bookmarks", + "Preferences", +) FIREFOX_FILES = ("places.sqlite", "cookies.sqlite", "logins.json", "key4.db", "prefs.js") MAX_FILE_BYTES = 50 * 1024 * 1024 MAX_TOTAL_BYTES = 256 * 1024 * 1024 @@ -38,14 +53,23 @@ class BrowserDataBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent browser-data artifact contract.""" return CollectorMetadata( - id="core.browser.browser_data_backup", name="Browser data backup", version="4.0.0", + id="core.browser.browser_data_backup", + name="Browser data backup", + version="4.0.0", specialty=Specialty.BROWSER, output_media_types=("application/octet-stream",), description="Copies bounded local profile evidence from Edge, Chrome, Firefox, Opera, and Opera GX.", - author="Logicytics", supported_platforms=("win32",), - capabilities=(Capability.FILESYSTEM_READ, Capability.BROWSER_DATA, Capability.SENSITIVE_FILES), - sensitive_data_categories=("browser_history", "cookies", "credentials"), default_profiles=("deep",), - timeout_seconds=300, maximum_output_bytes=256 * 1024 * 1024, + author="Logicytics", + supported_platforms=("win32",), + capabilities=( + Capability.FILESYSTEM_READ, + Capability.BROWSER_DATA, + Capability.SENSITIVE_FILES, + ), + sensitive_data_categories=("browser_history", "cookies", "credentials"), + default_profiles=("deep",), + timeout_seconds=300, + maximum_output_bytes=256 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -61,10 +85,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: home = filesystem_adapter.home() local = filesystem_adapter.environment_path("LOCALAPPDATA", home / "AppData" / "Local") roaming = filesystem_adapter.environment_path("APPDATA", home / "AppData" / "Roaming") - chromium_roots = (("chrome", local / "Google" / "Chrome" / "User Data"), - ("edge", local / "Microsoft" / "Edge" / "User Data"), - ("opera", roaming / "Opera Software" / "Opera Stable"), - ("opera_gx", roaming / "Opera Software" / "Opera GX Stable")) + chromium_roots = ( + ("chrome", local / "Google" / "Chrome" / "User Data"), + ("edge", local / "Microsoft" / "Edge" / "User Data"), + ("opera", roaming / "Opera Software" / "Opera Stable"), + ("opera_gx", roaming / "Opera Software" / "Opera GX Stable"), + ) copied: list[Path] = [] copied_bytes = 0 context.report_progress("browser_data_backup_started") @@ -76,10 +102,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: profile_roots = [ path for path in filesystem_adapter.children(root) - if path.is_dir() and ( - path.name == "Default" - or path.name.startswith("Profile ") - ) + if path.is_dir() and (path.name == "Default" or path.name.startswith("Profile ")) ] except OSError: continue @@ -87,9 +110,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: for filename in CHROMIUM_FILES: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") - destination, copied_bytes = _copy_file(profile / filename, - context.workspace / "browser_data" / browser / profile.name / filename, - copied_bytes) + destination, copied_bytes = _copy_file( + profile / filename, + context.workspace / "browser_data" / browser / profile.name / filename, + copied_bytes, + ) if context.is_cancelled: if destination is not None: destination.unlink(missing_ok=True) @@ -109,9 +134,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: for copied_path in copied: copied_path.unlink(missing_ok=True) return CollectorResult(CollectorStatus.CANCELLED, "cancelled during browser data backup") - destination, copied_bytes = _copy_file(profile / filename, - context.workspace / "browser_data" / "firefox" / profile.name / filename, - copied_bytes) + destination, copied_bytes = _copy_file( + profile / filename, + context.workspace / "browser_data" / "firefox" / profile.name / filename, + copied_bytes, + ) if context.is_cancelled: if destination is not None: destination.unlink(missing_ok=True) @@ -121,18 +148,23 @@ def collect(self, context: CollectorContext) -> CollectorResult: if destination is not None: copied.append(destination) if not copied: - return CollectorResult(CollectorStatus.SKIPPED, - "no supported local browser profile data met the bounded backup policy") + return CollectorResult( + CollectorStatus.SKIPPED, + "no supported local browser profile data met the bounded backup policy", + ) artifacts = [] for path in copied: if context.is_cancelled: - for unpublished in copied[len(artifacts):]: + for unpublished in copied[len(artifacts) :]: unpublished.unlink(missing_ok=True) return CollectorResult.cancelled("cancelled during browser-data registration", tuple(artifacts)) artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) artifact_tuple = tuple(artifacts) - context.report_progress("browser_data_backup_finished", copied_files=len(artifact_tuple), - bytes_written=sum(item.size_bytes for item in artifact_tuple)) + context.report_progress( + "browser_data_backup_finished", + copied_files=len(artifact_tuple), + bytes_written=sum(item.size_bytes for item in artifact_tuple), + ) return CollectorResult.succeeded("browser data backup collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/diagnostics/sysinternals_report.py b/core/diagnostics/sysinternals_report.py index 68b978c4..861cc9b4 100644 --- a/core/diagnostics/sysinternals_report.py +++ b/core/diagnostics/sysinternals_report.py @@ -5,7 +5,14 @@ import os from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess @@ -20,14 +27,19 @@ class SysinternalsReportCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Sysinternals report artifact contract.""" return CollectorMetadata( - id="core.diagnostics.sysinternals_report", name="Sysinternals report", version="4.0.0", + id="core.diagnostics.sysinternals_report", + name="Sysinternals report", + version="4.0.0", specialty=Specialty.DIAGNOSTICS, output_media_types=("text/plain",), description="Reports supported Sysinternals binary/archive state and consolidates available tool output.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_diagnostics",), - default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=512 * 1024, + default_profiles=("deep",), + timeout_seconds=180, + maximum_output_bytes=512 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -41,17 +53,22 @@ def collect(self, context: CollectorContext) -> CollectorResult: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before Sysinternals collection") project_root = Path(__file__).resolve().parents[2] - search_roots = (project_root / "sysinternals", project_root / "tools" / "sysinternals", - Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Sysinternals") + search_roots = ( + project_root / "sysinternals", + project_root / "tools" / "sysinternals", + Path(os.environ.get("ProgramFiles", r"C:\Program Files")) / "Sysinternals", + ) archive_paths = tuple(root.with_suffix(".zip") for root in search_roots) sections: list[str] = ["Sysinternals report", ""] context.report_progress("sysinternals_report_started") for tool in TOOLS: - binary = next((root / f"{tool}.exe" for root in search_roots if (root / f"{tool}.exe").is_file()), None) + binary = next( + (root / f"{tool}.exe" for root in search_roots if (root / f"{tool}.exe").is_file()), + None, + ) sections.append(f"## {tool}") if binary is None: - archive_state = "archive available" if any( - path.is_file() for path in archive_paths) else "binary and archive missing" + archive_state = "archive available" if any(path.is_file() for path in archive_paths) else "binary and archive missing" sections.extend((f"status: {archive_state}", "")) continue try: @@ -59,8 +76,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: except OSError as error: sections.extend((f"status: execution error: {error}", "")) continue - output = (completed.stdout + ( - "\n" if completed.stdout and completed.stderr else "") + completed.stderr).strip() + output = (completed.stdout + ("\n" if completed.stdout and completed.stderr else "") + completed.stderr).strip() sections.extend((f"status: executed (exit {completed.returncode})", output[:MAX_OUTPUT_CHARS], "")) report = "\n".join(sections) output_path = context.workspace / "sysinternals_report.txt" diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index 7be31cea..2f746f60 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -11,11 +18,7 @@ def _is_access_denied(detail: str, return_code: int) -> bool: """Recognize textual and HRESULT access-denied results from manage-bde.""" normalized = detail.casefold() - return ( - return_code == 2147749891 - or "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - ) + return return_code == 2147749891 or "permission denied" in normalized or ("access" in normalized and "denied" in normalized) class BitlockerStatusCollector(CoreCollector): @@ -57,8 +60,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"manage-bde exit code {completed.returncode}" if _is_access_denied(detail, completed.returncode): - return CollectorResult(CollectorStatus.SKIPPED, - "BitLocker status access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "BitLocker status access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "BitLocker status query failed", errors=(detail,)) output = context.workspace / "bitlocker_status.txt" output.write_text(completed.stdout, encoding="utf-8") diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 4d35c0ee..7351ecbb 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -5,13 +5,20 @@ import getpass import json import platform -from datetime import datetime, timezone +from datetime import UTC, datetime -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus -from logicytics.platform_adapters import network_adapter as socket, windows_api_adapter +from logicytics.platform_adapters import network_adapter as socket from logicytics.platform_adapters import process_adapter as subprocess -from logicytics.platform_adapters import which +from logicytics.platform_adapters import which, windows_api_adapter def _is_access_denied(detail: str) -> bool: @@ -79,18 +86,24 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "BitLocker volume access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "BitLocker volume access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query failed", errors=(detail,)) try: volumes = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "BitLocker volume query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(volumes, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "BitLocker volume query returned an unexpected result") report = { - "collected_at": datetime.now(timezone.utc).isoformat(), + "collected_at": datetime.now(UTC).isoformat(), "user": getpass.getuser(), "is_administrator": _is_administrator(), "hostname": socket.gethostname(), diff --git a/core/event_log/application_events.py b/core/event_log/application_events.py index 1b38258d..994cdff2 100644 --- a/core/event_log/application_events.py +++ b/core/event_log/application_events.py @@ -78,9 +78,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "Application event-log access was denied for the current account", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Application event-log access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Application event-log query failed", errors=(detail,)) output = context.workspace / "application_events.csv" output.write_text(completed.stdout, encoding="utf-8") @@ -89,8 +91,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Application event-log export") artifact = context.artifacts.register_file(output, media_type="text/csv") event_count = max(0, len(completed.stdout.splitlines()) - 1) - context.report_progress("application_events_finished", event_count=event_count, - bytes_written=artifact.size_bytes) + context.report_progress( + "application_events_finished", + event_count=event_count, + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("Application event-log sample collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/event_log/security_events.py b/core/event_log/security_events.py index 91cf71ea..8a554ded 100644 --- a/core/event_log/security_events.py +++ b/core/event_log/security_events.py @@ -21,11 +21,7 @@ def _is_access_denied(detail: str) -> bool: """Recognize common access-denied wording emitted by PowerShell event queries.""" normalized = detail.casefold() - return ( - "permission denied" in normalized - or "unauthorized" in normalized - or ("access" in normalized and "denied" in normalized) - ) + return "permission denied" in normalized or "unauthorized" in normalized or ("access" in normalized and "denied" in normalized) class SecurityEventsCollector(CoreCollector): @@ -35,14 +31,21 @@ class SecurityEventsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Security-event sample and output limit.""" return CollectorMetadata( - id="core.event_log.security_events", name="Security event log", version="4.0.0", + id="core.event_log.security_events", + name="Security event log", + version="4.0.0", specialty=Specialty.EVENT_LOG, output_media_types=("text/csv",), - description="Exports up to 1,000 local Windows Security events as CSV.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + description="Exports up to 1,000 local Windows Security events as CSV.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("event_logs",), - parallel_safe=True, resource_class=ResourceClass.GENERAL, - default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=8 * 1024 * 1024, + parallel_safe=True, + resource_class=ResourceClass.GENERAL, + default_profiles=("deep",), + timeout_seconds=90, + maximum_output_bytes=8 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -62,15 +65,23 @@ def collect(self, context: CollectorContext) -> CollectorResult: f"Get-WinEvent -LogName Security -MaxEvents {_MAX_EVENTS} | " "Select-Object TimeCreated, Id, LevelDisplayName, ProviderName, Message | ConvertTo-Csv -NoTypeInformation" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=75) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=75, + ) if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Security event-log query") if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "Security event-log access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Security event-log access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Security event-log query failed", errors=(detail,)) output = context.workspace / "security_events.csv" output.write_text(completed.stdout, encoding="utf-8") diff --git a/core/filesystem/sensitive_file_inventory.py b/core/filesystem/sensitive_file_inventory.py index 5fb5c476..7c6ae2ce 100644 --- a/core/filesystem/sensitive_file_inventory.py +++ b/core/filesystem/sensitive_file_inventory.py @@ -4,15 +4,57 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter -KEYWORDS = ("password", "secret", "code", "login", "api", "key", "token", "auth", "credential", "private", - "certificate", "ssh", "pgp", "wallet") -EXTENSIONS = {".txt", ".csv", ".json", ".xml", ".yml", ".yaml", ".ini", ".cfg", ".conf", ".log", ".pdf", ".doc", - ".docx", ".xls", ".xlsx", ".zip", ".db", ".sqlite", ".pem", ".key", ".ppk"} +KEYWORDS = ( + "password", + "secret", + "code", + "login", + "api", + "key", + "token", + "auth", + "credential", + "private", + "certificate", + "ssh", + "pgp", + "wallet", +) +EXTENSIONS = { + ".txt", + ".csv", + ".json", + ".xml", + ".yml", + ".yaml", + ".ini", + ".cfg", + ".conf", + ".log", + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".zip", + ".db", + ".sqlite", + ".pem", + ".key", + ".ppk", +} MAX_FILE_BYTES = 10 * 1024 * 1024 @@ -33,14 +75,19 @@ class SensitiveFileInventoryCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent sensitive-file inventory artifact contract.""" return CollectorMetadata( - id="core.filesystem.sensitive_file_inventory", name="Sensitive file inventory", version="4.0.0", + id="core.filesystem.sensitive_file_inventory", + name="Sensitive file inventory", + version="4.0.0", specialty=Specialty.FILESYSTEM, output_media_types=("application/octet-stream",), description="Finds and copies bounded supported files with sensitive-data keywords in their names.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), - sensitive_data_categories=("credentials", "private_keys", "personal_documents"), default_profiles=("deep",), - timeout_seconds=300, maximum_output_bytes=256 * 1024 * 1024, + supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + sensitive_data_categories=("credentials", "private_keys", "personal_documents"), + default_profiles=("deep",), + timeout_seconds=300, + maximum_output_bytes=256 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -112,19 +159,24 @@ def collect(self, context: CollectorContext) -> CollectorResult: copied_path.unlink(missing_ok=True) return CollectorResult(CollectorStatus.CANCELLED, "cancelled during sensitive-file copy") if not copied: - return CollectorResult(CollectorStatus.SKIPPED, - "no sensitive-named supported files met the bounded inventory policy") + return CollectorResult( + CollectorStatus.SKIPPED, + "no sensitive-named supported files met the bounded inventory policy", + ) artifacts = [] for path in copied: if context.is_cancelled: - for unpublished in copied[len(artifacts):]: + for unpublished in copied[len(artifacts) :]: unpublished.unlink(missing_ok=True) return CollectorResult.cancelled("cancelled during sensitive-file registration", tuple(artifacts)) artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) artifact_tuple = tuple(artifacts) - context.report_progress("sensitive_file_inventory_finished", scanned_directories=scanned_directories, - copied_files=len(artifact_tuple), - bytes_written=sum(item.size_bytes for item in artifact_tuple)) + context.report_progress( + "sensitive_file_inventory_finished", + scanned_directories=scanned_directories, + copied_files=len(artifact_tuple), + bytes_written=sum(item.size_bytes for item in artifact_tuple), + ) return CollectorResult.succeeded("sensitive file inventory collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/filesystem/startup_folder_entries.py b/core/filesystem/startup_folder_entries.py index 283bd54f..24c732d5 100644 --- a/core/filesystem/startup_folder_entries.py +++ b/core/filesystem/startup_folder_entries.py @@ -6,7 +6,14 @@ import os from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -69,8 +76,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: children = sorted(filesystem_adapter.children(folder), key=lambda path: path.name.casefold()) except OSError as error: - return CollectorResult(CollectorStatus.SKIPPED, "Startup-folder access was denied", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Startup-folder access was denied", + errors=(str(error),), + ) for child in children: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Startup-folder collection") @@ -80,19 +90,26 @@ def collect(self, context: CollectorContext) -> CollectorResult: stat = child.stat() except OSError: continue - entries.append({ - "name": child.name, - "path": str(child), - "is_directory": str(child.is_dir()).lower(), - "size_bytes": stat.st_size, - "modified_epoch": int(stat.st_mtime), - }) + entries.append( + { + "name": child.name, + "path": str(child), + "is_directory": str(child.is_dir()).lower(), + "size_bytes": stat.st_size, + "modified_epoch": int(stat.st_mtime), + } + ) output = context.workspace / "startup_folder_entries.json" - output.write_text(json.dumps({"entries": entries, "missing_folders": missing_folders}, indent=2) + "\n", - encoding="utf-8") + output.write_text( + json.dumps({"entries": entries, "missing_folders": missing_folders}, indent=2) + "\n", + encoding="utf-8", + ) artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("startup_folder_entries_finished", entry_count=len(entries), - bytes_written=artifact.size_bytes) + context.report_progress( + "startup_folder_entries_finished", + entry_count=len(entries), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("Startup-folder entries collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/filesystem/system_drive_listing.py b/core/filesystem/system_drive_listing.py index 0b23a552..ee63f09d 100644 --- a/core/filesystem/system_drive_listing.py +++ b/core/filesystem/system_drive_listing.py @@ -5,7 +5,14 @@ from collections import deque from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -47,13 +54,18 @@ class SystemDriveListingCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the filesystem-read system-drive listing contract.""" return CollectorMetadata( - id="core.filesystem.system_drive_listing", name="System drive listing", version="4.0.0", + id="core.filesystem.system_drive_listing", + name="System drive listing", + version="4.0.0", specialty=Specialty.FILESYSTEM, output_media_types=("text/plain",), description="Exports a configurable, bounded recursive listing of the Windows system drive.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), - sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=180, + supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ,), + sensitive_data_categories=("filesystem_metadata",), + default_profiles=("deep",), + timeout_seconds=180, maximum_output_bytes=8 * 1024 * 1024, ) @@ -73,17 +85,24 @@ def collect(self, context: CollectorContext) -> CollectorResult: maximum_entries = _setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) maximum_depth = _setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) root = filesystem_adapter.system_drive_root() - lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}", - "# scheduling=run_supervisor"] + lines = [ + f"# system_drive={root}", + f"# max_entries={maximum_entries}", + f"# max_depth={maximum_depth}", + "# scheduling=run_supervisor", + ] pending = deque([(root, 0)]) entries = 0 truncated = False - context.report_progress("system_drive_listing_started", max_entries=maximum_entries, max_depth=maximum_depth, - scheduling="run_supervisor") + context.report_progress( + "system_drive_listing_started", + max_entries=maximum_entries, + max_depth=maximum_depth, + scheduling="run_supervisor", + ) while pending and entries < maximum_entries: if context.is_cancelled: - return CollectorResult(CollectorStatus.CANCELLED, - "cancelled during system-drive listing collection") + return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive listing collection") directory, depth = pending.popleft() names, children = _scan_directory(directory) for name in names: @@ -103,8 +122,12 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "system_drive_listing.txt" output.write_text("\n".join(lines) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") - context.report_progress("system_drive_listing_finished", entry_count=entries, truncated=str(truncated).lower(), - bytes_written=artifact.size_bytes) + context.report_progress( + "system_drive_listing_finished", + entry_count=entries, + truncated=str(truncated).lower(), + bytes_written=artifact.size_bytes, + ) summary = "system-drive listing collected" if not truncated else "system-drive listing collected with configured entry limit" return CollectorResult.succeeded(summary, (artifact,)) diff --git a/core/filesystem/system_drive_tree.py b/core/filesystem/system_drive_tree.py index 8c0cf0ba..8618060b 100644 --- a/core/filesystem/system_drive_tree.py +++ b/core/filesystem/system_drive_tree.py @@ -4,7 +4,14 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -27,13 +34,18 @@ class SystemDriveTreeCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the filesystem-read, bounded system-drive tree artifact contract.""" return CollectorMetadata( - id="core.filesystem.system_drive_tree", name="System drive tree", version="4.0.0", + id="core.filesystem.system_drive_tree", + name="System drive tree", + version="4.0.0", specialty=Specialty.FILESYSTEM, output_media_types=("text/plain",), description="Exports a configurable bounded recursive directory tree for the Windows system drive.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ,), - sensitive_data_categories=("filesystem_metadata",), default_profiles=("deep",), timeout_seconds=120, + supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ,), + sensitive_data_categories=("filesystem_metadata",), + default_profiles=("deep",), + timeout_seconds=120, maximum_output_bytes=8 * 1024 * 1024, ) @@ -53,13 +65,16 @@ def collect(self, context: CollectorContext) -> CollectorResult: maximum_entries = _bounded_setting(context.settings, "max_entries", _DEFAULT_MAX_ENTRIES, _HARD_MAX_ENTRIES) maximum_depth = _bounded_setting(context.settings, "max_depth", _DEFAULT_MAX_DEPTH, _HARD_MAX_DEPTH) root = filesystem_adapter.system_drive_root() - lines = [f"# system_drive={root}", f"# max_entries={maximum_entries}", f"# max_depth={maximum_depth}"] + lines = [ + f"# system_drive={root}", + f"# max_entries={maximum_entries}", + f"# max_depth={maximum_depth}", + ] entries = 0 skipped = 0 truncated = False context.report_progress("system_drive_tree_started", max_entries=maximum_entries, max_depth=maximum_depth) - for current, directories, filenames in filesystem_adapter.walk(root, topdown=True, followlinks=False, - onerror=lambda _error: None): + for current, directories, filenames in filesystem_adapter.walk(root, topdown=True, followlinks=False, onerror=lambda _error: None): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during system-drive tree collection") relative = Path(current).relative_to(root) @@ -80,8 +95,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "system_drive_tree.txt" output.write_text("\n".join(lines) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") - context.report_progress("system_drive_tree_finished", entry_count=entries, skipped_directories=skipped, - truncated=str(truncated).lower(), bytes_written=artifact.size_bytes) + context.report_progress( + "system_drive_tree_finished", + entry_count=entries, + skipped_directories=skipped, + truncated=str(truncated).lower(), + bytes_written=artifact.size_bytes, + ) summary = "system-drive tree collected" if not truncated else "system-drive tree collected with configured entry limit" return CollectorResult.succeeded(summary, (artifact,)) diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index ef3be251..1d403d41 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,13 +30,19 @@ class BatteryStatusCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated battery-status JSON artifact contract.""" return CollectorMetadata( - id="core.hardware.battery_status", name="Battery status", version="4.0.0", specialty=Specialty.HARDWARE, + id="core.hardware.battery_status", + name="Battery status", + version="4.0.0", + specialty=Specialty.HARDWARE, output_media_types=("application/json",), description="Exports local battery name, status, charge, capacity, and estimated runtime metadata.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=128 * 1024, + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=128 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -50,19 +63,30 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Select-Object Name, BatteryStatus, EstimatedChargeRemaining, EstimatedRunTime, DesignCapacity, FullChargeCapacity, Status | " "ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "battery-status access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "battery-status access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "battery-status query failed", errors=(detail,)) try: batteries = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "battery-status query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "battery-status query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(batteries, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "battery-status query returned an unexpected result") output = context.workspace / "battery_status.json" diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py index 3966fc8a..7dfbe581 100644 --- a/core/hardware/display_adapters.py +++ b/core/hardware/display_adapters.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -17,11 +24,18 @@ class DisplayAdaptersCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated display-adapter JSON artifact contract.""" return CollectorMetadata( - id="core.hardware.display_adapters", name="Display adapters", version="4.0.0", specialty=Specialty.HARDWARE, + id="core.hardware.display_adapters", + name="Display adapters", + version="4.0.0", + specialty=Specialty.HARDWARE, output_media_types=("application/json",), description="Exports local display-adapter names, driver versions, resolution, and memory metadata.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("hardware_inventory",), default_profiles=("deep",), timeout_seconds=30, + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("hardware_inventory",), + default_profiles=("deep",), + timeout_seconds=30, maximum_output_bytes=256 * 1024, ) @@ -43,8 +57,13 @@ def collect(self, context: CollectorContext) -> CollectorResult: "VideoProcessor, AdapterRAM, CurrentHorizontalResolution, CurrentVerticalResolution, CurrentRefreshRate " "| ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=25) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=25, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" status = CollectorStatus.SKIPPED if "denied" in detail.casefold() else CollectorStatus.FAILED @@ -52,8 +71,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: adapters = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "display-adapter query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(adapters, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "display-adapter query returned an unexpected result") output = context.workspace / "display_adapters.json" diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index 7a539f67..6273014b 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -57,10 +57,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before optional-feature collection") context.report_progress("windows_features_started") - command = ( - "Get-WindowsOptionalFeature -Online | " - "Select-Object FeatureName, State | ConvertTo-Json -Depth 2" - ) + command = "Get-WindowsOptionalFeature -Online | Select-Object FeatureName, State | ConvertTo-Json -Depth 2" completed = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], capture_output=True, @@ -93,8 +90,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "windows_features.json" output.write_text(json.dumps(features, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("windows_features_finished", feature_count=len(features), - bytes_written=artifact.size_bytes) + context.report_progress( + "windows_features_finished", + feature_count=len(features), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("Windows optional features collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/integration/legacy_code_outputs.py b/core/integration/legacy_code_outputs.py index c38de1e3..5de01a72 100644 --- a/core/integration/legacy_code_outputs.py +++ b/core/integration/legacy_code_outputs.py @@ -17,12 +17,30 @@ from logicytics.platform_adapters import filesystem_adapter _EVIDENCE_EXTENSIONS = { - ".csv", ".dot", ".evtx", ".html", ".htm", ".json", ".log", ".reg", - ".svg", ".txt", ".xml", ".zip", + ".csv", + ".dot", + ".evtx", + ".html", + ".htm", + ".json", + ".log", + ".reg", + ".svg", + ".txt", + ".xml", + ".zip", } _EXCLUDED_DIRECTORIES = { - ".git", ".idea", ".mypy_cache", ".pytest_cache", ".venv", "__pycache__", - "lib", "libs", "site-packages", "venv", + ".git", + ".idea", + ".mypy_cache", + ".pytest_cache", + ".venv", + "__pycache__", + "lib", + "libs", + "site-packages", + "venv", } _MAXIMUM_FILES = 500 _MAXIMUM_FILE_BYTES = 64 * 1024 * 1024 @@ -78,29 +96,24 @@ def collect(self, context: CollectorContext) -> CollectorResult: candidates: list[Path] = [] for path in sorted( - filesystem_adapter.recursive(code_root), - key=lambda candidate: candidate.as_posix(), + filesystem_adapter.recursive(code_root), + key=lambda candidate: candidate.as_posix(), ): if context.is_cancelled: - return CollectorResult.cancelled( - "cancelled during legacy CODE discovery" - ) + return CollectorResult.cancelled("cancelled during legacy CODE discovery") relative = path.relative_to(code_root) - if any( - part.casefold() in _EXCLUDED_DIRECTORIES - for part in relative.parts - ): + if any(part.casefold() in _EXCLUDED_DIRECTORIES for part in relative.parts): continue try: if ( - not path.is_file() - or path.is_symlink() - or path.resolve().parent != (code_root / relative.parent).resolve() - or path.suffix.casefold() not in _EVIDENCE_EXTENSIONS - or path.stat().st_size > _MAXIMUM_FILE_BYTES + not path.is_file() + or path.is_symlink() + or path.resolve().parent != (code_root / relative.parent).resolve() + or path.suffix.casefold() not in _EVIDENCE_EXTENSIONS + or path.stat().st_size > _MAXIMUM_FILE_BYTES ): continue except OSError: @@ -145,9 +158,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: destination, media_type="application/octet-stream", evidence_kind=EvidenceKind.RAW, - transformations=( - "imported from historical CODE output", - ), + transformations=("imported from historical CODE output",), ) ) @@ -165,9 +176,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: errors.append(f"{relative.as_posix()}: {error}") if not artifacts and not errors: - return CollectorResult.skipped( - "no generated legacy CODE evidence matched the import policy" - ) + return CollectorResult.skipped("no generated legacy CODE evidence matched the import policy") if errors: return CollectorResult.partial( @@ -179,10 +188,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress( "legacy_code_outputs_finished", imported_files=len(artifacts), - bytes_written=sum( - artifact.size_bytes - for artifact in artifacts - ), + bytes_written=sum(artifact.size_bytes for artifact in artifacts), ) return CollectorResult.succeeded( diff --git a/core/media/media_backup.py b/core/media/media_backup.py index 3271a6eb..f2e2fc22 100644 --- a/core/media/media_backup.py +++ b/core/media/media_backup.py @@ -2,11 +2,18 @@ from __future__ import annotations -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -23,12 +30,18 @@ class MediaBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent media backup artifact contract.""" return CollectorMetadata( - id="core.media.media_backup", name="Pictures and Videos backup", version="4.0.0", specialty=Specialty.MEDIA, + id="core.media.media_backup", + name="Pictures and Videos backup", + version="4.0.0", + specialty=Specialty.MEDIA, output_media_types=("application/octet-stream",), description="Copies bounded JPG, JPEG, PNG, and MP4 files from current-user Pictures and Videos folders.", - author="Logicytics", supported_platforms=("win32",), + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), - sensitive_data_categories=("personal_media",), default_profiles=("deep",), timeout_seconds=300, + sensitive_data_categories=("personal_media",), + default_profiles=("deep",), + timeout_seconds=300, maximum_output_bytes=512 * 1024 * 1024, ) @@ -75,8 +88,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if size > MAX_FILE_BYTES or copied_bytes + size > MAX_TOTAL_BYTES: skipped_files += 1 continue - timestamp = datetime.fromtimestamp(candidate.stat().st_mtime, timezone.utc).strftime( - "%Y%m%dT%H%M%SZ") + timestamp = datetime.fromtimestamp(candidate.stat().st_mtime, UTC).strftime("%Y%m%dT%H%M%SZ") destination = destination_root / category / f"{candidate.stem}_{timestamp}{candidate.suffix.casefold()}" suffix = 1 while destination.exists(): @@ -101,13 +113,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: artifacts = [] for path in copied: if context.is_cancelled: - for unpublished in copied[len(artifacts):]: + for unpublished in copied[len(artifacts) :]: unpublished.unlink(missing_ok=True) return CollectorResult.cancelled("cancelled during media registration", tuple(artifacts)) artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) artifact_tuple = tuple(artifacts) - context.report_progress("media_backup_finished", copied_files=len(artifacts), skipped_files=skipped_files, - bytes_written=sum(item.size_bytes for item in artifact_tuple)) + context.report_progress( + "media_backup_finished", + copied_files=len(artifacts), + skipped_files=skipped_files, + bytes_written=sum(item.size_bytes for item in artifact_tuple), + ) return CollectorResult.succeeded("current-user media backup collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/memory/memory_snapshot.py b/core/memory/memory_snapshot.py index ed5c6795..ff507126 100644 --- a/core/memory/memory_snapshot.py +++ b/core/memory/memory_snapshot.py @@ -3,10 +3,16 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime -from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics import global_memory_status +from logicytics import ( + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, + global_memory_status, +) from logicytics.contracts import CollectorContext, CollectorStatus @@ -51,7 +57,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: except OSError as error: return CollectorResult(CollectorStatus.FAILED, "could not read Windows memory status", errors=(str(error),)) report = { - "collected_at": datetime.now(timezone.utc).isoformat(), + "collected_at": datetime.now(UTC).isoformat(), "memory_load_percent": status.dwMemoryLoad, "physical_memory": { "total_bytes": status.ullTotalPhys, diff --git a/core/network/active_connections.py b/core/network/active_connections.py index ed47ba0e..c31d09cc 100644 --- a/core/network/active_connections.py +++ b/core/network/active_connections.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -59,15 +66,21 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "netstat access was denied for the current account", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "netstat access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "active-connection query failed", errors=(detail,)) output = context.workspace / "active_connections.txt" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") connection_count = sum(1 for line in completed.stdout.splitlines() if line.lstrip().startswith(("TCP", "UDP"))) - context.report_progress("active_connections_finished", connection_count=connection_count, - bytes_written=artifact.size_bytes) + context.report_progress( + "active_connections_finished", + connection_count=connection_count, + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("active network connections collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/adapter_statistics.py b/core/network/adapter_statistics.py index 5ad51667..bda39a05 100644 --- a/core/network/adapter_statistics.py +++ b/core/network/adapter_statistics.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -67,14 +74,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "adapter-statistics access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "adapter-statistics access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query failed", errors=(detail,)) try: statistics = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "adapter-statistics query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(statistics, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "adapter-statistics query returned an unexpected result") output = context.workspace / "adapter_statistics.json" diff --git a/core/network/bandwidth_sample.py b/core/network/bandwidth_sample.py index fd4ffd74..ed61d18a 100644 --- a/core/network/bandwidth_sample.py +++ b/core/network/bandwidth_sample.py @@ -5,7 +5,14 @@ import json import time -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -22,8 +29,7 @@ def _setting(settings: object, key: str, default: int, maximum: int) -> int: def _interval_setting(settings: object) -> float: """Return the configured finite positive sampling interval.""" - value = settings.get("interval_seconds", _DEFAULT_INTERVAL_SECONDS) if isinstance(settings, - dict) else _DEFAULT_INTERVAL_SECONDS + value = settings.get("interval_seconds", _DEFAULT_INTERVAL_SECONDS) if isinstance(settings, dict) else _DEFAULT_INTERVAL_SECONDS if isinstance(value, (int, float)) and not isinstance(value, bool) and 0.1 <= value <= 60: return float(value) return float(_DEFAULT_INTERVAL_SECONDS) @@ -42,14 +48,19 @@ class BandwidthSampleCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated bounded bandwidth-sampling artifact contract.""" return CollectorMetadata( - id="core.network.bandwidth_sample", name="Network bandwidth sample", version="4.0.0", + id="core.network.bandwidth_sample", + name="Network bandwidth sample", + version="4.0.0", specialty=Specialty.NETWORK, output_media_types=("application/json",), description="Calculates local per-interface average and peak bandwidth from adapter counter samples.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_identifiers",), - default_profiles=("deep",), timeout_seconds=90, maximum_output_bytes=512 * 1024, + default_profiles=("deep",), + timeout_seconds=90, + maximum_output_bytes=512 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -72,26 +83,42 @@ def collect(self, context: CollectorContext) -> CollectorResult: for index in range(samples): if context.is_cancelled: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during bandwidth sampling") - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=20) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=20, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "bandwidth-sample access was denied for the current account", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "bandwidth-sample access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query failed", errors=(detail,)) try: raw = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "bandwidth-sample query returned invalid JSON", + errors=(str(error),), + ) records = raw if isinstance(raw, list) else [raw] if not all(isinstance(record, dict) for record in records): return CollectorResult(CollectorStatus.FAILED, "bandwidth-sample query returned an unexpected result") - observations.append({str(record.get("Name", "unavailable")): { - "received": int(record.get("ReceivedBytes", 0)), "sent": int(record.get("SentBytes", 0))} for record in - records}) + observations.append( + { + str(record.get("Name", "unavailable")): { + "received": int(record.get("ReceivedBytes", 0)), + "sent": int(record.get("SentBytes", 0)), + } + for record in records + } + ) if index + 1 < samples: time.sleep(interval) rates: dict[str, dict[str, float]] = {} @@ -101,10 +128,16 @@ def collect(self, context: CollectorContext) -> CollectorResult: continue receive = max(0, counters["received"] - previous[name]["received"]) / interval sent = max(0, counters["sent"] - previous[name]["sent"]) / interval - values = rates.setdefault(name, {"receive_average_bytes_per_second": 0.0, - "receive_peak_bytes_per_second": 0.0, - "send_average_bytes_per_second": 0.0, - "send_peak_bytes_per_second": 0.0, "sample_intervals": 0.0}) + values = rates.setdefault( + name, + { + "receive_average_bytes_per_second": 0.0, + "receive_peak_bytes_per_second": 0.0, + "send_average_bytes_per_second": 0.0, + "send_peak_bytes_per_second": 0.0, + "sample_intervals": 0.0, + }, + ) values["sample_intervals"] += 1 values["receive_average_bytes_per_second"] += receive values["send_average_bytes_per_second"] += sent @@ -115,11 +148,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: values["send_average_bytes_per_second"] /= values["sample_intervals"] output = context.workspace / "bandwidth_sample.json" output.write_text( - json.dumps({"sample_count": samples, "interval_seconds": interval, "interfaces": rates}, indent=2, - sort_keys=True) + "\n", encoding="utf-8") + json.dumps( + {"sample_count": samples, "interval_seconds": interval, "interfaces": rates}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("bandwidth_sample_finished", interface_count=len(rates), - bytes_written=artifact.size_bytes) + context.report_progress( + "bandwidth_sample_finished", + interface_count=len(rates), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("network bandwidth sample collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/connection_processes.py b/core/network/connection_processes.py index b26c0f2e..d502f11b 100644 --- a/core/network/connection_processes.py +++ b/core/network/connection_processes.py @@ -5,7 +5,14 @@ import csv import io -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -32,7 +39,14 @@ def _parse_connections(netstat_output: str) -> list[dict[str, str]]: else: continue connections.append( - {"protocol": protocol, "local_endpoint": local, "remote_endpoint": remote, "state": state, "pid": pid}) + { + "protocol": protocol, + "local_endpoint": local, + "remote_endpoint": remote, + "state": state, + "pid": pid, + } + ) return connections @@ -82,27 +96,47 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before connection-process collection") context.report_progress("connection_processes_started") netstat = subprocess.run(["netstat", "-ano"], capture_output=True, check=False, text=True, timeout=25) - tasklist = subprocess.run(["tasklist", "/fo", "csv", "/nh"], capture_output=True, check=False, text=True, - timeout=25) + tasklist = subprocess.run( + ["tasklist", "/fo", "csv", "/nh"], + capture_output=True, + check=False, + text=True, + timeout=25, + ) failures = [result for result in (netstat, tasklist) if result.returncode != 0] if failures: detail = "\n".join(result.stderr.strip() or f"command exit code {result.returncode}" for result in failures) if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "connection-process access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "connection-process access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "connection-process query failed", errors=(detail,)) processes = _parse_processes(tasklist.stdout) rows = _parse_connections(netstat.stdout) output = context.workspace / "connection_processes.csv" with output.open("w", encoding="utf-8", newline="") as stream: - writer = csv.DictWriter(stream, fieldnames=("protocol", "local_endpoint", "remote_endpoint", "state", "pid", - "process_name")) + writer = csv.DictWriter( + stream, + fieldnames=( + "protocol", + "local_endpoint", + "remote_endpoint", + "state", + "pid", + "process_name", + ), + ) writer.writeheader() for row in rows: writer.writerow({**row, "process_name": processes.get(row["pid"], "unavailable")}) artifact = context.artifacts.register_file(output, media_type="text/csv") - context.report_progress("connection_processes_finished", connection_count=len(rows), - bytes_written=artifact.size_bytes) + context.report_progress( + "connection_processes_finished", + connection_count=len(rows), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("connection-process associations collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/network/dns_cache.py b/core/network/dns_cache.py index 4f187105..8af06f18 100644 --- a/core/network/dns_cache.py +++ b/core/network/dns_cache.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -21,13 +28,19 @@ class DnsCacheCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated sensitive DNS-cache artifact contract.""" return CollectorMetadata( - id="core.network.dns_cache", name="DNS resolver cache", version="4.0.0", specialty=Specialty.NETWORK, + id="core.network.dns_cache", + name="DNS resolver cache", + version="4.0.0", + specialty=Specialty.NETWORK, output_media_types=("text/plain",), description="Exports local DNS resolver cache records through read-only ipconfig output.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("dns_history",), - default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=4 * 1024 * 1024, + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=4 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -47,8 +60,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "DNS-cache access was denied for the current account", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "DNS-cache access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "DNS-cache query failed", errors=(detail,)) output = context.workspace / "dns_cache.txt" output.write_text(completed.stdout, encoding="utf-8") diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index 064201e9..6c4d99f4 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,13 +30,19 @@ class FirewallProfilesCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated firewall-profile JSON artifact contract.""" return CollectorMetadata( - id="core.network.firewall_profiles", name="Firewall profiles", version="4.0.0", specialty=Specialty.NETWORK, + id="core.network.firewall_profiles", + name="Firewall profiles", + version="4.0.0", + specialty=Specialty.NETWORK, output_media_types=("application/json",), description="Exports local Domain, Private, and Public Windows firewall profile settings.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=256 * 1024, + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=256 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -47,22 +60,35 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("firewall_profiles_started") command = ( "Get-NetFirewallProfile | " - "Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, NotifyOnListen, AllowInboundRules, AllowLocalFirewallRules, AllowLocalIPsecRules, LogFileName | " + "Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, " + "NotifyOnListen, AllowInboundRules, AllowLocalFirewallRules, " + "AllowLocalIPsecRules, LogFileName | " "ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "firewall-profile access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "firewall-profile access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "firewall-profile query failed", errors=(detail,)) try: profiles = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "firewall-profile query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(profiles, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "firewall-profile query returned an unexpected result") output = context.workspace / "firewall_profiles.json" diff --git a/core/network/network_adapters.py b/core/network/network_adapters.py index 0a8a2781..b64a636e 100644 --- a/core/network/network_adapters.py +++ b/core/network/network_adapters.py @@ -58,9 +58,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: ) if completed.returncode != 0: detail = completed.stderr.strip() or f"ipconfig exit code {completed.returncode}" - if "permission denied" in detail.casefold() or ( - "access" in detail.casefold() and "denied" in detail.casefold() - ): + if "permission denied" in detail.casefold() or ("access" in detail.casefold() and "denied" in detail.casefold()): return CollectorResult( CollectorStatus.SKIPPED, "ipconfig access was denied for the current account", diff --git a/core/network/network_identity.py b/core/network/network_identity.py index 0d44539a..4d67ba12 100644 --- a/core/network/network_identity.py +++ b/core/network/network_identity.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime from logicytics import ( Capability, @@ -62,7 +62,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: except socket.gaierror as error: resolver_error = str(error) report = { - "collected_at": datetime.now(timezone.utc).isoformat(), + "collected_at": datetime.now(UTC).isoformat(), "hostname": hostname, "addresses": sorted(addresses), } diff --git a/core/network/network_interfaces.py b/core/network/network_interfaces.py index 21a30725..c6976b15 100644 --- a/core/network/network_interfaces.py +++ b/core/network/network_interfaces.py @@ -5,7 +5,14 @@ import ipaddress import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -98,22 +105,34 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "network-interface access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "network-interface access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "network-interface query failed", errors=(detail,)) try: interfaces = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "network-interface query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "network-interface query returned invalid JSON", + errors=(str(error),), + ) records = interfaces if isinstance(interfaces, list) else [interfaces] if not all(isinstance(record, dict) for record in records): return CollectorResult(CollectorStatus.FAILED, "network-interface query returned an unexpected result") output = context.workspace / "network_interfaces.json" - output.write_text(json.dumps(_enrich_ipv4_networks(records), indent=2, sort_keys=True) + "\n", encoding="utf-8") + output.write_text( + json.dumps(_enrich_ipv4_networks(records), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("network_interfaces_finished", interface_count=len(records), - bytes_written=artifact.size_bytes) + context.report_progress( + "network_interfaces_finished", + interface_count=len(records), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("network-interface inventory collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/packet/connection_graph.py b/core/packet/connection_graph.py index 43ff200d..9c05b7cd 100644 --- a/core/packet/connection_graph.py +++ b/core/packet/connection_graph.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -28,13 +35,10 @@ def render_connection_graph(command_output: str) -> str: def quote(value: str) -> str: """Escape one label for safe inclusion in a quoted DOT string.""" - return '"' + value.replace('\\', '\\\\').replace('"', '\\"') + '"' + return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"' lines = ["digraph connection_graph {", " rankdir=LR;"] - lines.extend( - f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" - for source, destination, protocol in sorted(edges) - ) + lines.extend(f" {quote(source)} -> {quote(destination)} [label={quote(protocol)}];" for source, destination, protocol in sorted(edges)) lines.append("}") return "\n".join(lines) + "\n" @@ -46,13 +50,19 @@ class ConnectionGraphCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated network graph artifact contract.""" return CollectorMetadata( - id="core.packet.connection_graph", name="Connection graph", version="4.0.0", specialty=Specialty.PACKET, + id="core.packet.connection_graph", + name="Connection graph", + version="4.0.0", + specialty=Specialty.PACKET, output_media_types=("text/vnd.graphviz",), description="Exports a DOT source/destination graph with TCP or UDP protocol edge labels.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("network_metadata",), - default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=2 * 1024 * 1024, + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=2 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -72,8 +82,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"netstat exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "connection graph access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "connection graph access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "connection graph query failed", errors=(detail,)) rendered = render_connection_graph(completed.stdout) output = context.workspace / "connection_graph.dot" diff --git a/core/packet/packet_capture.py b/core/packet/packet_capture.py index e4ab35ec..a3830fa0 100644 --- a/core/packet/packet_capture.py +++ b/core/packet/packet_capture.py @@ -3,11 +3,10 @@ from __future__ import annotations import csv +import select import struct import time -import select - from logicytics import ( Capability, CollectorMetadata, @@ -35,10 +34,15 @@ def _packet_row(payload: bytes) -> dict[str, str] | None: destination = socket.inet_ntoa(payload[16:20]) source_port = destination_port = "" if protocol in {6, 17} and len(payload) >= header_length + 4: - source_port, destination_port = (str(value) for value in - struct.unpack("!HH", payload[header_length:header_length + 4])) - return {"source_ip": source, "destination_ip": destination, "protocol": names.get(protocol, str(protocol)), - "source_port": source_port, "destination_port": destination_port, "packet_bytes": str(len(payload))} + source_port, destination_port = (str(value) for value in struct.unpack("!HH", payload[header_length : header_length + 4])) + return { + "source_ip": source, + "destination_ip": destination, + "protocol": names.get(protocol, str(protocol)), + "source_port": source_port, + "destination_port": destination_port, + "packet_bytes": str(len(payload)), + } class PacketCaptureCollector(CoreCollector): @@ -48,13 +52,24 @@ class PacketCaptureCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicitly approved packet-capture CSV artifact contract.""" return CollectorMetadata( - id="core.packet.packet_capture", name="IPv4 packet capture", version="4.0.0", specialty=Specialty.PACKET, + id="core.packet.packet_capture", + name="IPv4 packet capture", + version="4.0.0", + specialty=Specialty.PACKET, output_media_types=("text/csv",), - description="Captures bounded IPv4 packet metadata without saving packet payloads.", author="Logicytics", + description="Captures bounded IPv4 packet metadata without saving packet payloads.", + author="Logicytics", supported_platforms=("win32",), - capabilities=(Capability.NETWORK, Capability.PACKET_CAPTURE, Capability.ELEVATED_PRIVILEGES), - privilege_level=PrivilegeLevel.ELEVATED, network_access=NetworkAccess.LOCAL, - sensitive_data_categories=("network_metadata",), default_profiles=("deep",), timeout_seconds=90, + capabilities=( + Capability.NETWORK, + Capability.PACKET_CAPTURE, + Capability.ELEVATED_PRIVILEGES, + ), + privilege_level=PrivilegeLevel.ELEVATED, + network_access=NetworkAccess.LOCAL, + sensitive_data_categories=("network_metadata",), + default_profiles=("deep",), + timeout_seconds=90, maximum_output_bytes=2 * 1024 * 1024, ) @@ -67,8 +82,10 @@ def validate(self, context: CollectorContext) -> ValidationResult: timeout = context.setting_float("timeout_seconds", 10.0) retry_window = context.setting_float("retry_window_seconds", 0.0) except (TypeError, ValueError): - return ValidationResult(False, reasons=( - "packet_count, timeout_seconds, and retry_window_seconds must be numeric",)) + return ValidationResult( + False, + reasons=("packet_count, timeout_seconds, and retry_window_seconds must be numeric",), + ) if not 1 <= count <= 10_000 or not 1 <= timeout <= 60 or not 0 <= retry_window <= 60: return ValidationResult( False, @@ -152,10 +169,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: deadline = time.monotonic() + timeout retry_deadline = time.monotonic() + retry_window - while ( - observation_count < count - and time.monotonic() < deadline - ): + while observation_count < count and time.monotonic() < deadline: if context.is_cancelled: early_result = CollectorResult( CollectorStatus.CANCELLED, diff --git a/core/process/detailed_processes.py b/core/process/detailed_processes.py index 887ba38d..be7759e8 100644 --- a/core/process/detailed_processes.py +++ b/core/process/detailed_processes.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -59,15 +66,21 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"tasklist exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, "tasklist access was denied for the current account", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "tasklist access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "detailed tasklist query failed", errors=(detail,)) output = context.workspace / "detailed_processes.csv" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/csv") process_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) - context.report_progress("detailed_processes_finished", process_count=process_count, - bytes_written=artifact.size_bytes) + context.report_progress( + "detailed_processes_finished", + process_count=process_count, + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("detailed process list collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/process/memory_map.py b/core/process/memory_map.py index 67c3e464..045c0aa8 100644 --- a/core/process/memory_map.py +++ b/core/process/memory_map.py @@ -5,17 +5,34 @@ import json from pathlib import Path -from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics import ProcessMemoryCounters, MemoryBasicInformation, get_process_memory_info, \ - virtual_query, get_mapped_file_name, get_current_process, pointer_value +from logicytics import ( + CollectorMetadata, + CollectorResult, + CoreCollector, + MemoryBasicInformation, + ProcessMemoryCounters, + Specialty, + ValidationResult, + get_current_process, + get_mapped_file_name, + get_process_memory_info, + pointer_value, + virtual_query, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter def _permissions(protection: int) -> str: """Return a readable Windows page-protection summary.""" - values = {0x02: "read", 0x04: "read_write", 0x08: "write_copy", 0x20: "execute_read", 0x40: "execute_read_write", - 0x80: "execute_write_copy"} + values = { + 0x02: "read", + 0x04: "read_write", + 0x08: "write_copy", + 0x20: "execute_read", + 0x40: "execute_read_write", + 0x80: "execute_write_copy", + } return values.get(protection & 0xFF, "unreadable") @@ -26,13 +43,19 @@ class MemoryMapCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the bounded memory-region JSON artifact contract.""" return CollectorMetadata( - id="core.process.memory_map", name="Process memory map", version="4.0.0", specialty=Specialty.PROCESS, + id="core.process.memory_map", + name="Process memory map", + version="4.0.0", + specialty=Specialty.PROCESS, output_media_types=("application/json",), description="Exports readable virtual-memory region addresses, sizes, permissions, paths, and process RSS.", author="Logicytics", - supported_platforms=("win32",), sensitive_data_categories=("process_metadata",), default_profiles=("deep",), + supported_platforms=("win32",), + sensitive_data_categories=("process_metadata",), + default_profiles=("deep",), capabilities=(), - timeout_seconds=90, maximum_output_bytes=64 * 1024 * 1024, + timeout_seconds=90, + maximum_output_bytes=64 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -71,11 +94,7 @@ def setting_int(name: str, default: int) -> int: reasons=("memory-map limits must be integers",), ) - if ( - not 1 <= maximum <= 100_000 - or not 1_024 <= output_limit <= 64 * 1024 * 1024 - or safety_margin < 0 - ): + if not 1 <= maximum <= 100_000 or not 1_024 <= output_limit <= 64 * 1024 * 1024 or safety_margin < 0: return ValidationResult( False, reasons=("invalid memory-map region, output, or safety limits",), @@ -101,12 +120,8 @@ def collect(self, context: CollectorContext) -> CollectorResult: 100 * 1024 * 1024, ) - configured_directory = Path( - context.setting_str("dump_directory", "memory_maps") - ) - output_directory = ( - context.workspace / configured_directory - ).resolve() + configured_directory = Path(context.setting_str("dump_directory", "memory_maps")) + output_directory = (context.workspace / configured_directory).resolve() try: output_directory.relative_to(context.workspace.resolve()) @@ -151,10 +166,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if base_address is None: base_address = address - readable = ( - memory.State == 0x1000 - and not memory.Protect & 0x101 - ) + readable = memory.State == 0x1000 and not memory.Protect & 0x101 if readable: mapped_path = get_mapped_file_name( @@ -191,14 +203,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: "cancelled during memory-map serialization", ) - serialized = json.dumps( - { - "region_count": len(regions), - "truncated": truncated, - "regions": regions, - }, - indent=2, - ) + "\n" + serialized = ( + json.dumps( + { + "region_count": len(regions), + "truncated": truncated, + "regions": regions, + }, + indent=2, + ) + + "\n" + ) serialized_size = len(serialized.encode("utf-8")) @@ -208,10 +223,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: regions.pop() truncated = True - if ( - filesystem_adapter.disk_usage(context.workspace).free - < serialized_size + safety_margin - ): + if filesystem_adapter.disk_usage(context.workspace).free < serialized_size + safety_margin: return CollectorResult( CollectorStatus.SKIPPED, "insufficient free disk space after configured memory-map safety margin", @@ -254,11 +266,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: bytes_written=artifact.size_bytes, ) - summary = ( - "process memory map collected" - if not truncated - else "process memory map collected with configured output truncation" - ) + summary = "process memory map collected" if not truncated else "process memory map collected with configured output truncation" return CollectorResult.succeeded( summary, diff --git a/core/process/process_memory.py b/core/process/process_memory.py index 0dbccca3..51586c00 100644 --- a/core/process/process_memory.py +++ b/core/process/process_memory.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,11 +30,18 @@ class ProcessMemoryCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated process-memory JSON artifact contract.""" return CollectorMetadata( - id="core.process.process_memory", name="Process memory", version="4.0.0", specialty=Specialty.PROCESS, + id="core.process.process_memory", + name="Process memory", + version="4.0.0", + specialty=Specialty.PROCESS, output_media_types=("application/json",), description="Exports aggregate working-set, private, and virtual memory counters for local processes.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("process_metadata",), default_profiles=("deep",), timeout_seconds=60, + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("process_metadata",), + default_profiles=("deep",), + timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, ) @@ -49,19 +63,30 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Select-Object Id, ProcessName, WorkingSet64, PrivateMemorySize64, VirtualMemorySize64, HandleCount, CPU, StartTime | " "ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=55) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=55, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "process-memory access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "process-memory access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "process-memory query failed", errors=(detail,)) try: processes = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "process-memory query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "process-memory query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(processes, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "process-memory query returned an unexpected result") output = context.workspace / "process_memory.json" diff --git a/core/registry/hklm_backup.py b/core/registry/hklm_backup.py index 7c8f9733..9d158246 100644 --- a/core/registry/hklm_backup.py +++ b/core/registry/hklm_backup.py @@ -2,8 +2,15 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -12,11 +19,7 @@ def _is_access_denied(detail: str) -> bool: """Recognize Windows permission-denied or elevation-required wording.""" normalized = detail.casefold() - return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "requires elevation" in normalized - ) + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) or "requires elevation" in normalized class HklmBackupCollector(CoreCollector): @@ -26,14 +29,23 @@ class HklmBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent HKLM registry-export artifact contract.""" return CollectorMetadata( - id="core.registry.hklm_backup", name="HKLM registry backup", version="4.0.0", + id="core.registry.hklm_backup", + name="HKLM registry backup", + version="4.0.0", specialty=Specialty.REGISTRY, output_media_types=("text/plain",), description="Exports the local HKLM hive as a .reg backup after explicit sensitive-data approval.", - author="Logicytics", supported_platforms=("win32",), - capabilities=(Capability.REGISTRY_READ, Capability.SUBPROCESS, Capability.SENSITIVE_FILES), - sensitive_data_categories=("registry", "system_configuration", "credentials"), default_profiles=("deep",), - timeout_seconds=300, maximum_output_bytes=2 * 1024 * 1024 * 1024, + author="Logicytics", + supported_platforms=("win32",), + capabilities=( + Capability.REGISTRY_READ, + Capability.SUBPROCESS, + Capability.SENSITIVE_FILES, + ), + sensitive_data_categories=("registry", "system_configuration", "credentials"), + default_profiles=("deep",), + timeout_seconds=300, + maximum_output_bytes=2 * 1024 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -52,14 +64,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: output = context.workspace / "hklm_backup.reg" completed = subprocess.run( ["reg", "export", "HKLM", str(output), "/y"], - capture_output=True, check=False, text=True, timeout=290, + capture_output=True, + check=False, + text=True, + timeout=290, ) detail = completed.stderr.strip() or completed.stdout.strip() if completed.returncode != 0: message = detail or f"reg.exe exit code {completed.returncode}" if _is_access_denied(message): - return CollectorResult(CollectorStatus.SKIPPED, - "HKLM registry export was denied for the current account", errors=(message,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "HKLM registry export was denied for the current account", + errors=(message,), + ) return CollectorResult(CollectorStatus.FAILED, "HKLM registry export failed", errors=(message,)) if not output.is_file() or output.stat().st_size == 0: return CollectorResult(CollectorStatus.FAILED, "HKLM registry export produced no backup artifact") diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py index 8e54cc03..776ba0dc 100644 --- a/core/registry/installed_applications.py +++ b/core/registry/installed_applications.py @@ -3,9 +3,16 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import registry_adapter as winreg @@ -52,11 +59,16 @@ def _installed_applications() -> list[dict[str, str | None]]: if identity in seen: continue seen.add(identity) - applications.append({"display_name": display_name, "display_version": version or None, - "publisher": _registry_value(key, "Publisher"), - "install_date": _registry_value(key, "InstallDate"), - "install_location": _registry_value(key, "InstallLocation"), - "uninstall_key": key_name}) + applications.append( + { + "display_name": display_name, + "display_version": version or None, + "publisher": _registry_value(key, "Publisher"), + "install_date": _registry_value(key, "InstallDate"), + "install_location": _registry_value(key, "InstallLocation"), + "uninstall_key": key_name, + } + ) except OSError: continue return sorted(applications, key=lambda item: (item["display_name"] or "").casefold()) @@ -69,14 +81,19 @@ class InstalledApplicationsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the registry-read, bounded installed-application artifact contract.""" return CollectorMetadata( - id="core.registry.installed_applications", name="Installed applications", version="4.0.0", + id="core.registry.installed_applications", + name="Installed applications", + version="4.0.0", specialty=Specialty.REGISTRY, output_media_types=("application/json",), description="Exports installed application names, versions, publishers, and install metadata from uninstall keys.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), + supported_platforms=("win32",), + capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, + default_profiles=("deep",), + timeout_seconds=60, + maximum_output_bytes=4 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -99,11 +116,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: applications = _installed_applications() output = context.workspace / "installed_applications.json" output.write_text( - json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "applications": applications}, indent=2, - sort_keys=True) + "\n", encoding="utf-8") + json.dumps( + {"collected_at": datetime.now(UTC).isoformat(), "applications": applications}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("installed_applications_finished", application_count=len(applications), - bytes_written=artifact.size_bytes) + context.report_progress( + "installed_applications_finished", + application_count=len(applications), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("installed applications collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py index 0d5a5e78..e84677af 100644 --- a/core/registry/startup_applications.py +++ b/core/registry/startup_applications.py @@ -3,17 +3,40 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import registry_adapter as winreg _RUN_PATHS = ( - ("HKEY_CURRENT_USER", winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Run"), - ("HKEY_CURRENT_USER", winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\RunOnce"), - ("HKEY_LOCAL_MACHINE", winreg.HKEY_LOCAL_MACHINE, r"Software\Microsoft\Windows\CurrentVersion\Run"), - ("HKEY_LOCAL_MACHINE", winreg.HKEY_LOCAL_MACHINE, r"Software\Microsoft\Windows\CurrentVersion\RunOnce"), + ( + "HKEY_CURRENT_USER", + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Run", + ), + ( + "HKEY_CURRENT_USER", + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\RunOnce", + ), + ( + "HKEY_LOCAL_MACHINE", + winreg.HKEY_LOCAL_MACHINE, + r"Software\Microsoft\Windows\CurrentVersion\Run", + ), + ( + "HKEY_LOCAL_MACHINE", + winreg.HKEY_LOCAL_MACHINE, + r"Software\Microsoft\Windows\CurrentVersion\RunOnce", + ), ) @@ -44,13 +67,19 @@ class StartupApplicationsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the registry-read startup-application artifact contract.""" return CollectorMetadata( - id="core.registry.startup_applications", name="Startup applications", version="4.0.0", + id="core.registry.startup_applications", + name="Startup applications", + version="4.0.0", specialty=Specialty.REGISTRY, output_media_types=("application/json",), - description="Exports standard user and machine Run/RunOnce startup registry entries.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), + description="Exports standard user and machine Run/RunOnce startup registry entries.", + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=30, maximum_output_bytes=512 * 1024, + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=512 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -67,11 +96,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: entries = _startup_entries() output = context.workspace / "startup_applications.json" output.write_text( - json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "entries": entries}, indent=2, - sort_keys=True) + "\n", encoding="utf-8") + json.dumps( + {"collected_at": datetime.now(UTC).isoformat(), "entries": entries}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("startup_applications_finished", entry_count=len(entries), - bytes_written=artifact.size_bytes) + context.report_progress( + "startup_applications_finished", + entry_count=len(entries), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("startup applications collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/ssh/ssh_backup.py b/core/ssh/ssh_backup.py index bf3a8775..18ad860c 100644 --- a/core/ssh/ssh_backup.py +++ b/core/ssh/ssh_backup.py @@ -5,8 +5,15 @@ import zipfile from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -21,13 +28,23 @@ class SshBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent SSH archive artifact contract.""" return CollectorMetadata( - id="core.ssh.ssh_backup", name="SSH directory backup", version="4.0.0", specialty=Specialty.SSH, + id="core.ssh.ssh_backup", + name="SSH directory backup", + version="4.0.0", + specialty=Specialty.SSH, output_media_types=("application/zip",), description="Archives the current user's .ssh keys and configuration after explicit sensitive-data approval.", - author="Logicytics", supported_platforms=("win32",), - capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), - sensitive_data_categories=("private_keys", "ssh_configuration", "credentials"), default_profiles=("deep",), - timeout_seconds=120, maximum_output_bytes=128 * 1024 * 1024, + author="Logicytics", + supported_platforms=("win32",), + capabilities=( + Capability.FILESYSTEM_READ, + Capability.SENSITIVE_FILES, + Capability.PRIVATE_KEYS, + ), + sensitive_data_categories=("private_keys", "ssh_configuration", "credentials"), + default_profiles=("deep",), + timeout_seconds=120, + maximum_output_bytes=128 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -70,9 +87,9 @@ def collect(self, context: CollectorContext) -> CollectorResult: cancelled = False with zipfile.ZipFile( - archive, - "w", - compression=zipfile.ZIP_DEFLATED, + archive, + "w", + compression=zipfile.ZIP_DEFLATED, ) as output: try: candidates: list[Path] = sorted( @@ -102,10 +119,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: if not is_file or is_symlink: continue - if ( - size > MAX_FILE_BYTES - or source_bytes + size > MAX_ARCHIVE_SOURCE_BYTES - ): + if size > MAX_FILE_BYTES or source_bytes + size > MAX_ARCHIVE_SOURCE_BYTES: skipped_files += 1 continue @@ -145,9 +159,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: archive, media_type="application/zip", evidence_kind=EvidenceKind.RAW, - transformations=( - "archived from the current user's SSH directory", - ), + transformations=("archived from the current user's SSH directory",), ) context.report_progress( diff --git a/core/storage/logical_drives.py b/core/storage/logical_drives.py index fbfa9a7e..4ee32ea0 100644 --- a/core/storage/logical_drives.py +++ b/core/storage/logical_drives.py @@ -3,10 +3,19 @@ from __future__ import annotations import json -from datetime import datetime, timezone - -from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics import get_logical_drives, ularge_integer, get_disk_free_space, get_drive_type +from datetime import UTC, datetime + +from logicytics import ( + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, + get_disk_free_space, + get_drive_type, + get_logical_drives, + ularge_integer, +) from logicytics.contracts import CollectorContext, CollectorStatus _DRIVE_TYPES = { @@ -35,10 +44,10 @@ def _logical_drives() -> list[dict[str, int | str]]: free = ularge_integer() if not get_disk_free_space( - root, - available, - total, - free, + root, + available, + total, + free, ): continue @@ -98,7 +107,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: except OSError as error: return CollectorResult(CollectorStatus.FAILED, "could not read logical drives", errors=(str(error),)) report = { - "collected_at": datetime.now(timezone.utc).isoformat(), + "collected_at": datetime.now(UTC).isoformat(), "drives": drives, } output = context.workspace / "logical_drives.json" diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index 8e358468..27b52e9e 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -53,15 +60,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"mountvol exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "mounted-volume access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "mounted-volume access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "mounted-volume query failed", errors=(detail,)) output = context.workspace / "mounted_volumes.txt" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") volume_count = sum(1 for line in completed.stdout.splitlines() if line.strip().startswith("\\\\?\\Volume{")) - context.report_progress("mounted_volumes_finished", volume_count=volume_count, - bytes_written=artifact.size_bytes) + context.report_progress("mounted_volumes_finished", volume_count=volume_count, bytes_written=artifact.size_bytes) return CollectorResult.succeeded("mounted-volume mappings collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index 7d51626a..cf3b27f6 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -66,14 +73,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "physical-disk CIM access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "physical-disk CIM access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query failed", errors=(detail,)) try: physical_disks = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "physical-disk CIM query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(physical_disks, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "physical-disk CIM query returned an unexpected result") output = context.workspace / "physical_disks.json" diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index 889055d5..14ba3d79 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -3,16 +3,32 @@ from __future__ import annotations import json -from datetime import datetime, timezone - -from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult -from logicytics import get_volume_information, get_disk_free_space, ularge_integer, \ - create_unicode_buffer, dword +from datetime import UTC, datetime + +from logicytics import ( + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, + create_unicode_buffer, + dword, + get_disk_free_space, + get_volume_information, + ularge_integer, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import windows_api_adapter -_DRIVE_TYPES = {0: "unknown", 1: "no_root_directory", 2: "removable", 3: "fixed", 4: "remote", 5: "optical", - 6: "ram_disk"} +_DRIVE_TYPES = { + 0: "unknown", + 1: "no_root_directory", + 2: "removable", + 3: "fixed", + 4: "remote", + 5: "optical", + 6: "ram_disk", +} def _volume_details() -> list[dict[str, int | str]]: @@ -32,10 +48,10 @@ def _volume_details() -> list[dict[str, int | str]]: free = ularge_integer() if not get_disk_free_space( - root, - available, - total, - free, + root, + available, + total, + free, ): continue @@ -53,15 +69,17 @@ def _volume_details() -> list[dict[str, int | str]]: flags, filesystem, ) - volumes.append({ - "root": root, - "drive_type": _DRIVE_TYPES.get(kernel32.GetDriveTypeW(root), "unknown"), - "filesystem": filesystem.value if information_available else "unavailable", - "volume_name": label.value if information_available else "unavailable", - "total_bytes": total.value, - "free_bytes": free.value, - "available_bytes": available.value, - }) + volumes.append( + { + "root": root, + "drive_type": _DRIVE_TYPES.get(kernel32.GetDriveTypeW(root), "unknown"), + "filesystem": filesystem.value if information_available else "unavailable", + "volume_name": label.value if information_available else "unavailable", + "total_bytes": total.value, + "free_bytes": free.value, + "available_bytes": available.value, + } + ) return volumes @@ -72,10 +90,16 @@ class VolumeDetailsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the capability-free detailed volume artifact contract.""" return CollectorMetadata( - id="core.storage.volume_details", name="Volume details", version="4.0.0", specialty=Specialty.STORAGE, + id="core.storage.volume_details", + name="Volume details", + version="4.0.0", + specialty=Specialty.STORAGE, output_media_types=("application/json",), - description="Exports mounted drive type, filesystem, label, and capacity metadata.", author="Logicytics", - supported_platforms=("win32",), default_profiles=("deep",), timeout_seconds=15, + description="Exports mounted drive type, filesystem, label, and capacity metadata.", + author="Logicytics", + supported_platforms=("win32",), + default_profiles=("deep",), + timeout_seconds=15, capabilities=(), maximum_output_bytes=128 * 1024, ) @@ -98,12 +122,21 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: volumes = _volume_details() except OSError as error: - return CollectorResult(CollectorStatus.FAILED, "could not read detailed volume metadata", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "could not read detailed volume metadata", + errors=(str(error),), + ) output = context.workspace / "volume_details.json" output.write_text( - json.dumps({"collected_at": datetime.now(timezone.utc).isoformat(), "volumes": volumes}, indent=2, - sort_keys=True) + "\n", encoding="utf-8") + json.dumps( + {"collected_at": datetime.now(UTC).isoformat(), "volumes": volumes}, + indent=2, + sort_keys=True, + ) + + "\n", + encoding="utf-8", + ) artifact = context.artifacts.register_file(output, media_type="application/json") context.report_progress("volume_details_finished", volume_count=len(volumes), bytes_written=artifact.size_bytes) return CollectorResult.succeeded("detailed volume metadata collected", (artifact,)) diff --git a/core/system/bios_info.py b/core/system/bios_info.py index 6405ba63..fe5981c1 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -40,7 +40,7 @@ def render_bios_table(bios: dict[str, str | None]) -> str: ( "", '', - " BIOS information", + ' BIOS information', " ", "

BIOS information

", " ", diff --git a/core/system/defender_status.py b/core/system/defender_status.py index 25791215..d734098d 100644 --- a/core/system/defender_status.py +++ b/core/system/defender_status.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -14,10 +21,10 @@ def _is_unavailable(detail: str) -> bool: """Recognize missing Defender-provider and access-denied results without failing a run.""" normalized = detail.casefold() return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "not recognized" in normalized - or "cannot find" in normalized + "permission denied" in normalized + or ("access" in normalized and "denied" in normalized) + or "not recognized" in normalized + or "cannot find" in normalized ) @@ -72,14 +79,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_unavailable(detail): - return CollectorResult(CollectorStatus.SKIPPED, "Microsoft Defender status is unavailable", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Microsoft Defender status is unavailable", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Microsoft Defender status query failed", errors=(detail,)) try: status = json.loads(completed.stdout) if completed.stdout.strip() else {} except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Defender status query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "Defender status query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(status, dict): return CollectorResult(CollectorStatus.FAILED, "Defender status query returned an unexpected result") output = context.workspace / "defender_status.json" diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index ea8370a2..5b9dc7fb 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,13 +30,19 @@ class EnvironmentPostureCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the bounded, subprocess-gated system posture artifact contract.""" return CollectorMetadata( - id="core.system.environment_posture", name="Environment posture", version="4.0.0", + id="core.system.environment_posture", + name="Environment posture", + version="4.0.0", specialty=Specialty.SYSTEM, output_media_types=("application/json",), description="Exports administrator state, UAC settings, and PowerShell execution policies.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("system_configuration",), default_profiles=("deep",), - timeout_seconds=45, maximum_output_bytes=256 * 1024, + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=256 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -60,20 +73,28 @@ def collect(self, context: CollectorContext) -> CollectorResult: ) completed = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=40, + capture_output=True, + check=False, + text=True, + timeout=40, ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "environment-posture access was denied for the current account", - errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "environment-posture access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "environment-posture query failed", errors=(detail,)) try: posture = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "environment-posture query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(posture, dict): return CollectorResult(CollectorStatus.FAILED, "environment-posture query returned an unexpected result") output = context.workspace / "environment_posture.json" diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index a6747241..e643843a 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -75,8 +75,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/csv") driver_count = sum(1 for line in completed.stdout.splitlines() if line.strip()) - context.report_progress("installed_drivers_finished", driver_count=driver_count, - bytes_written=artifact.size_bytes) + context.report_progress( + "installed_drivers_finished", + driver_count=driver_count, + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("installed-driver inventory collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index 31cfd6c7..944b509f 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,14 +30,19 @@ class InstalledUpdatesCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated installed-updates JSON artifact contract.""" return CollectorMetadata( - id="core.system.installed_updates", name="Installed Windows updates", version="4.0.0", + id="core.system.installed_updates", + name="Installed Windows updates", + version="4.0.0", specialty=Specialty.SYSTEM, output_media_types=("application/json",), description="Exports local installed hotfix identifiers, descriptions, and install dates.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=45, maximum_output_bytes=512 * 1024, + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=512 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -47,19 +59,30 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before installed-update collection") context.report_progress("installed_updates_started") command = "Get-HotFix | Select-Object HotFixID, Description, InstalledBy, InstalledOn | ConvertTo-Json -Depth 3" - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=40) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "installed-update access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "installed-update access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "installed-update query failed", errors=(detail,)) try: updates = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "installed-update query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "installed-update query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(updates, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "installed-update query returned an unexpected result") output = context.workspace / "installed_updates.json" diff --git a/core/system/local_accounts.py b/core/system/local_accounts.py index 87f9518b..c1ac811e 100644 --- a/core/system/local_accounts.py +++ b/core/system/local_accounts.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -70,8 +77,11 @@ def collect(self, context: CollectorContext) -> CollectorResult: try: accounts = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "local-account query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "local-account query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(accounts, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "local-account query returned an unexpected result") output = context.workspace / "local_accounts.json" diff --git a/core/system/scheduled_tasks.py b/core/system/scheduled_tasks.py index 760b275f..95b5e35e 100644 --- a/core/system/scheduled_tasks.py +++ b/core/system/scheduled_tasks.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,13 +30,19 @@ class ScheduledTasksCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated scheduled-task JSON artifact contract.""" return CollectorMetadata( - id="core.system.scheduled_tasks", name="Scheduled tasks", version="4.0.0", specialty=Specialty.SYSTEM, + id="core.system.scheduled_tasks", + name="Scheduled tasks", + version="4.0.0", + specialty=Specialty.SYSTEM, output_media_types=("application/json",), description="Exports up to 1,000 local scheduled-task names, paths, authors, descriptions, and states.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=2 * 1024 * 1024, + default_profiles=("deep",), + timeout_seconds=60, + maximum_output_bytes=2 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -46,8 +59,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before scheduled-task collection") context.report_progress("scheduled_tasks_started") command = ( - "Get-ScheduledTask | Select-Object -First 1000 TaskName, TaskPath, State, Author, Description, URI | " - "ConvertTo-Json -Depth 3" + "Get-ScheduledTask | Select-Object -First 1000 TaskName, TaskPath, State, Author, Description, URI | ConvertTo-Json -Depth 3" ) completed = subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], diff --git a/core/system/session_snapshot.py b/core/system/session_snapshot.py index 54a56245..c3b3803c 100644 --- a/core/system/session_snapshot.py +++ b/core/system/session_snapshot.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -75,14 +82,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "session-snapshot access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "session-snapshot access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "session-snapshot query failed", errors=(detail,)) try: snapshot = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "session-snapshot query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(snapshot, dict): return CollectorResult(CollectorStatus.FAILED, "session-snapshot query returned an unexpected result") output = context.workspace / "session_snapshot.json" diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index 7b676e15..86094345 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,13 +30,19 @@ class SystemDiagnosticsCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated system-diagnostics JSON artifact contract.""" return CollectorMetadata( - id="core.system.system_diagnostics", name="System diagnostics", version="4.0.0", + id="core.system.system_diagnostics", + name="System diagnostics", + version="4.0.0", specialty=Specialty.SYSTEM, output_media_types=("application/json",), description="Exports architecture, CPU, page-size, and boot-time diagnostics through Windows CIM.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("system_configuration",), default_profiles=("deep",), - timeout_seconds=45, maximum_output_bytes=256 * 1024, + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("system_configuration",), + default_profiles=("deep",), + timeout_seconds=45, + maximum_output_bytes=256 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -47,24 +60,39 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("system_diagnostics_started") command = ( "$ErrorActionPreference = 'Stop'; $os = Get-CimInstance -ClassName Win32_OperatingSystem; " - "$cpu = @(Get-CimInstance -ClassName Win32_Processor | Select-Object Name, Architecture, AddressWidth, NumberOfCores, NumberOfLogicalProcessors, MaxClockSpeed); " - "[pscustomobject]@{ OperatingSystem = $os.Caption; Version = $os.Version; Architecture = $os.OSArchitecture; " - "Machine = $env:PROCESSOR_IDENTIFIER; PageSizeBytes = [Environment]::SystemPageSize; ProcessorCount = $cpu.Count; " - "Processors = $cpu; LastBootUpTime = $os.LastBootUpTime; CollectedAt = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 5" + "$cpu = @(Get-CimInstance -ClassName Win32_Processor | " + "Select-Object Name, Architecture, AddressWidth, NumberOfCores, " + "NumberOfLogicalProcessors, MaxClockSpeed); " + "[pscustomobject]@{ OperatingSystem = $os.Caption; Version = $os.Version; " + "Architecture = $os.OSArchitecture; Machine = $env:PROCESSOR_IDENTIFIER; " + "PageSizeBytes = [Environment]::SystemPageSize; ProcessorCount = $cpu.Count; " + "Processors = $cpu; LastBootUpTime = $os.LastBootUpTime; " + "CollectedAt = [DateTime]::UtcNow.ToString('o') } | ConvertTo-Json -Depth 5" + ) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=40, ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=40) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "system-diagnostics access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "system-diagnostics access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query failed", errors=(detail,)) try: diagnostics = json.loads(completed.stdout) except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "system-diagnostics query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(diagnostics, dict): return CollectorResult(CollectorStatus.FAILED, "system-diagnostics query returned an unexpected result") output = context.workspace / "system_diagnostics.json" diff --git a/core/system/system_info.py b/core/system/system_info.py index 8353a31e..00360425 100644 --- a/core/system/system_info.py +++ b/core/system/system_info.py @@ -6,9 +6,15 @@ import json import os import platform -from datetime import datetime, timezone +from datetime import UTC, datetime -from logicytics import CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import network_adapter as socket @@ -46,7 +52,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled before system inventory collection") context.report_progress("system_inventory_started") inventory = { - "collected_at": datetime.now(timezone.utc).isoformat(), + "collected_at": datetime.now(UTC).isoformat(), "hostname": socket.gethostname(), "username": getpass.getuser(), "operating_system": { diff --git a/core/system/windows_services.py b/core/system/windows_services.py index 7d81b5d0..86b9de03 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -23,13 +30,19 @@ class WindowsServicesCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the subprocess-gated Windows-services JSON artifact contract.""" return CollectorMetadata( - id="core.system.windows_services", name="Windows services", version="4.0.0", specialty=Specialty.SYSTEM, + id="core.system.windows_services", + name="Windows services", + version="4.0.0", + specialty=Specialty.SYSTEM, output_media_types=("application/json",), description="Exports local Windows service names, states, start modes, accounts, and executable paths.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, + default_profiles=("deep",), + timeout_seconds=60, + maximum_output_bytes=4 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -49,19 +62,30 @@ def collect(self, context: CollectorContext) -> CollectorResult: "Get-CimInstance -ClassName Win32_Service | " "Select-Object Name, DisplayName, State, StartMode, StartName, PathName, ProcessId | ConvertTo-Json -Depth 3" ) - completed = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", command], - capture_output=True, check=False, text=True, timeout=55) + completed = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", command], + capture_output=True, + check=False, + text=True, + timeout=55, + ) if completed.returncode != 0: detail = completed.stderr.strip() or f"PowerShell exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "Windows-service access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Windows-service access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Windows-service query failed", errors=(detail,)) try: services = json.loads(completed.stdout) if completed.stdout.strip() else [] except json.JSONDecodeError as error: - return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned invalid JSON", - errors=(str(error),)) + return CollectorResult( + CollectorStatus.FAILED, + "Windows-service query returned invalid JSON", + errors=(str(error),), + ) if not isinstance(services, (dict, list)): return CollectorResult(CollectorStatus.FAILED, "Windows-service query returned an unexpected result") output = context.workspace / "windows_services.json" diff --git a/core/system/windows_system_data_backup.py b/core/system/windows_system_data_backup.py index d71d8091..56820cd1 100644 --- a/core/system/windows_system_data_backup.py +++ b/core/system/windows_system_data_backup.py @@ -4,8 +4,15 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter @@ -19,14 +26,19 @@ class WindowsSystemDataBackupCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent Windows system-data backup contract.""" return CollectorMetadata( - id="core.system.windows_system_data_backup", name="Windows system-data backup", version="4.0.0", + id="core.system.windows_system_data_backup", + name="Windows system-data backup", + version="4.0.0", specialty=Specialty.SYSTEM, output_media_types=("application/octet-stream",), description="Copies bounded Group Policy, event-log, and Windows security-support evidence.", author="Logicytics", - supported_platforms=("win32",), capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), + supported_platforms=("win32",), + capabilities=(Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), sensitive_data_categories=("security_logs", "group_policy", "system_configuration"), - default_profiles=("deep",), timeout_seconds=180, maximum_output_bytes=256 * 1024 * 1024, + default_profiles=("deep",), + timeout_seconds=180, + maximum_output_bytes=256 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -48,9 +60,10 @@ def collect(self, context: CollectorContext) -> CollectorResult: ("event_logs", windows / "System32" / "winevt" / "Logs" / "Application.evtx"), ("event_logs", windows / "System32" / "winevt" / "Logs" / "Security.evtx"), ] - candidates.extend(("security_support", path) for path in filesystem_adapter.glob( - program_data / "Microsoft" / "Windows Defender" / "Support", "*.log" - )) + candidates.extend( + ("security_support", path) + for path in filesystem_adapter.glob(program_data / "Microsoft" / "Windows Defender" / "Support", "*.log") + ) copied: list[Path] = [] context.report_progress("windows_system_data_backup_started") for label, source in candidates: @@ -71,18 +84,23 @@ def collect(self, context: CollectorContext) -> CollectorResult: return CollectorResult(CollectorStatus.CANCELLED, "cancelled during Windows system-data backup") copied.append(destination) if not copied: - return CollectorResult(CollectorStatus.SKIPPED, - "no configured Windows system-data files met the bounded backup policy") + return CollectorResult( + CollectorStatus.SKIPPED, + "no configured Windows system-data files met the bounded backup policy", + ) artifacts = [] for path in copied: if context.is_cancelled: - for unpublished in copied[len(artifacts):]: + for unpublished in copied[len(artifacts) :]: unpublished.unlink(missing_ok=True) return CollectorResult.cancelled("cancelled during system-data registration", tuple(artifacts)) artifacts.append(context.artifacts.register_file(path, evidence_kind=EvidenceKind.RAW)) artifact_tuple = tuple(artifacts) - context.report_progress("windows_system_data_backup_finished", copied_files=len(artifact_tuple), - bytes_written=sum(item.size_bytes for item in artifact_tuple)) + context.report_progress( + "windows_system_data_backup_finished", + copied_files=len(artifact_tuple), + bytes_written=sum(item.size_bytes for item in artifact_tuple), + ) return CollectorResult.succeeded("Windows system-data backup collected", artifact_tuple) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/system/wmic_inventory.py b/core/system/wmic_inventory.py index 6a2ad771..54b0ddc3 100644 --- a/core/system/wmic_inventory.py +++ b/core/system/wmic_inventory.py @@ -27,10 +27,7 @@ def metadata(cls) -> CollectorMetadata: version="4.0.0", specialty=Specialty.SYSTEM, output_media_types=("text/plain",), - description=( - "Exports bounded computer-system identity and hardware fields through the " - "optional legacy WMIC executable." - ), + description=("Exports bounded computer-system identity and hardware fields through the optional legacy WMIC executable."), author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), @@ -71,10 +68,7 @@ def collect(self, context: CollectorContext) -> CollectorResult: executable, "computersystem", "get", - ( - "Name,Manufacturer,Model,SystemType,NumberOfProcessors," - "NumberOfLogicalProcessors,TotalPhysicalMemory" - ), + ("Name,Manufacturer,Model,SystemType,NumberOfProcessors,NumberOfLogicalProcessors,TotalPhysicalMemory"), "/format:list", ], capture_output=True, diff --git a/core/usb/usb_storage_inventory.py b/core/usb/usb_storage_inventory.py index b2fc1dd1..1a8d5899 100644 --- a/core/usb/usb_storage_inventory.py +++ b/core/usb/usb_storage_inventory.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime, timezone +from datetime import UTC, datetime from logicytics import ( Capability, @@ -107,12 +107,15 @@ def collect(self, context: CollectorContext) -> CollectorResult: "USBSTOR registry access was denied during collection", errors=(str(error),), ) - report = {"collected_at": datetime.now(timezone.utc).isoformat(), "devices": devices} + report = {"collected_at": datetime.now(UTC).isoformat(), "devices": devices} output = context.workspace / "usb_storage_inventory.json" output.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="application/json") - context.report_progress("usb_storage_inventory_finished", device_count=len(devices), - bytes_written=artifact.size_bytes) + context.report_progress( + "usb_storage_inventory_finished", + device_count=len(devices), + bytes_written=artifact.size_bytes, + ) return CollectorResult.succeeded("USB storage inventory collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/wireless/wifi_interfaces.py b/core/wireless/wifi_interfaces.py index d8965f05..c285bedb 100644 --- a/core/wireless/wifi_interfaces.py +++ b/core/wireless/wifi_interfaces.py @@ -4,7 +4,14 @@ import json -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -13,11 +20,7 @@ def _is_access_denied(detail: str) -> bool: """Recognize common permission-denied wording from netsh output.""" normalized = detail.casefold() - return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "requires elevation" in normalized - ) + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) or "requires elevation" in normalized def _normalize_interface_name(name: str) -> str: @@ -32,13 +35,19 @@ class WifiInterfacesCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the bounded, subprocess-gated wireless interface artifact contract.""" return CollectorMetadata( - id="core.wireless.wifi_interfaces", name="Wi-Fi interfaces", version="4.0.0", + id="core.wireless.wifi_interfaces", + name="Wi-Fi interfaces", + version="4.0.0", specialty=Specialty.WIRELESS, output_media_types=("application/json",), description="Exports local Wi-Fi interface state and normalized interface names without key material.", - author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - sensitive_data_categories=("network_configuration",), default_profiles=("deep",), - timeout_seconds=30, maximum_output_bytes=256 * 1024, + author="Logicytics", + supported_platforms=("win32",), + capabilities=(Capability.SUBPROCESS,), + sensitive_data_categories=("network_configuration",), + default_profiles=("deep",), + timeout_seconds=30, + maximum_output_bytes=256 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -56,14 +65,20 @@ def collect(self, context: CollectorContext) -> CollectorResult: context.report_progress("wifi_interfaces_started") completed = subprocess.run( ["netsh", "wlan", "show", "interfaces"], - capture_output=True, check=False, text=True, timeout=25, + capture_output=True, + check=False, + text=True, + timeout=25, ) detail = completed.stderr.strip() if completed.returncode != 0: message = detail or completed.stdout.strip() or f"netsh exit code {completed.returncode}" if _is_access_denied(message): - return CollectorResult(CollectorStatus.SKIPPED, - "Wi-Fi interface access was denied for the current account", errors=(message,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Wi-Fi interface access was denied for the current account", + errors=(message,), + ) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi interface query failed", errors=(message,)) interfaces: list[dict[str, str]] = [] diff --git a/core/wireless/wifi_profile_keys.py b/core/wireless/wifi_profile_keys.py index d2b2dc66..f75b01c6 100644 --- a/core/wireless/wifi_profile_keys.py +++ b/core/wireless/wifi_profile_keys.py @@ -4,8 +4,15 @@ from pathlib import Path -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, EvidenceKind, Specialty, \ - ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + EvidenceKind, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import filesystem_adapter, which from logicytics.platform_adapters import process_adapter as subprocess @@ -14,11 +21,7 @@ def _is_access_denied(detail: str) -> bool: """Recognize common Windows permission-denied or elevation-required wording.""" normalized = detail.casefold() - return ( - "permission denied" in normalized - or ("access" in normalized and "denied" in normalized) - or "requires elevation" in normalized - ) + return "permission denied" in normalized or ("access" in normalized and "denied" in normalized) or "requires elevation" in normalized class WifiProfileKeysCollector(CoreCollector): @@ -28,14 +31,19 @@ class WifiProfileKeysCollector(CoreCollector): def metadata(cls) -> CollectorMetadata: """Declare the explicit-consent wireless credential export contract.""" return CollectorMetadata( - id="core.wireless.wifi_profile_keys", name="Saved Wi-Fi profile keys", version="4.0.0", + id="core.wireless.wifi_profile_keys", + name="Saved Wi-Fi profile keys", + version="4.0.0", specialty=Specialty.WIRELESS, output_media_types=("application/xml",), description="Exports saved Wi-Fi profile XML including key material after explicit sensitive-data approval.", - author="Logicytics", supported_platforms=("win32",), + author="Logicytics", + supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS, Capability.SENSITIVE_FILES), - sensitive_data_categories=("wireless_profile_names", "credentials"), default_profiles=("deep",), - timeout_seconds=60, maximum_output_bytes=2 * 1024 * 1024, + sensitive_data_categories=("wireless_profile_names", "credentials"), + default_profiles=("deep",), + timeout_seconds=60, + maximum_output_bytes=2 * 1024 * 1024, ) def validate(self, context: CollectorContext) -> ValidationResult: @@ -55,26 +63,33 @@ def collect(self, context: CollectorContext) -> CollectorResult: export_directory.mkdir() completed = subprocess.run( ["netsh", "wlan", "export", "profile", f"folder={export_directory}", "key=clear"], - capture_output=True, check=False, text=True, timeout=55, + capture_output=True, + check=False, + text=True, + timeout=55, ) detail = completed.stderr.strip() or completed.stdout.strip() if completed.returncode != 0: message = detail or f"netsh exit code {completed.returncode}" if _is_access_denied(message): - return CollectorResult(CollectorStatus.SKIPPED, - "Wi-Fi profile-key access was denied for the current account", errors=(message,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Wi-Fi profile-key access was denied for the current account", + errors=(message,), + ) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile-key export failed", errors=(message,)) profiles = sorted(path for path in filesystem_adapter.glob(export_directory, "*.xml") if path.is_file()) if not profiles: return CollectorResult(CollectorStatus.SKIPPED, "no saved Wi-Fi profiles with key material were exported") artifacts = tuple( - context.artifacts.register_file( - Path(profile), media_type="application/xml", evidence_kind=EvidenceKind.RAW - ) + context.artifacts.register_file(Path(profile), media_type="application/xml", evidence_kind=EvidenceKind.RAW) for profile in profiles ) - context.report_progress("wifi_profile_keys_finished", profile_count=len(artifacts), - bytes_written=sum(item.size_bytes for item in artifacts)) + context.report_progress( + "wifi_profile_keys_finished", + profile_count=len(artifacts), + bytes_written=sum(item.size_bytes for item in artifacts), + ) return CollectorResult.succeeded("saved Wi-Fi profile keys collected", artifacts) def cleanup(self, context: CollectorContext) -> None: diff --git a/core/wireless/wifi_profiles.py b/core/wireless/wifi_profiles.py index a66eab16..47cfa14b 100644 --- a/core/wireless/wifi_profiles.py +++ b/core/wireless/wifi_profiles.py @@ -2,7 +2,14 @@ from __future__ import annotations -from logicytics import Capability, CollectorMetadata, CollectorResult, CoreCollector, Specialty, ValidationResult +from logicytics import ( + Capability, + CollectorMetadata, + CollectorResult, + CoreCollector, + Specialty, + ValidationResult, +) from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.platform_adapters import process_adapter as subprocess from logicytics.platform_adapters import which @@ -59,15 +66,17 @@ def collect(self, context: CollectorContext) -> CollectorResult: if completed.returncode != 0: detail = completed.stderr.strip() or f"netsh exit code {completed.returncode}" if _is_access_denied(detail): - return CollectorResult(CollectorStatus.SKIPPED, - "Wi-Fi profile access was denied for the current account", errors=(detail,)) + return CollectorResult( + CollectorStatus.SKIPPED, + "Wi-Fi profile access was denied for the current account", + errors=(detail,), + ) return CollectorResult(CollectorStatus.FAILED, "Wi-Fi profile query failed", errors=(detail,)) output = context.workspace / "wifi_profiles.txt" output.write_text(completed.stdout, encoding="utf-8") artifact = context.artifacts.register_file(output, media_type="text/plain") profile_count = sum(1 for line in completed.stdout.splitlines() if " : " in line) - context.report_progress("wifi_profiles_finished", profile_count=profile_count, - bytes_written=artifact.size_bytes) + context.report_progress("wifi_profiles_finished", profile_count=profile_count, bytes_written=artifact.size_bytes) return CollectorResult.succeeded("saved Wi-Fi profiles collected", (artifact,)) def cleanup(self, context: CollectorContext) -> None: diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 133365ba..df22c1fa 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -5,13 +5,13 @@ from logicytics.contracts import ( CONTRACT_VERSION, Capability, - Collector, CollectionEstimate, + Collector, CollectorMetadata, CollectorResult, CoreCollector, - EvidenceKind, EstimatedCost, + EvidenceKind, NetworkAccess, OutputPolicy, PluginCollector, @@ -23,11 +23,20 @@ Specialty, ValidationResult, ) -_APPLICATION_EXPORTS = frozenset({ - "CollectorFailureSnapshot", "CollectorSnapshot", "RunSnapshot", "load_configuration", "open_artifact", "plan_run", - "query_run", "read_artifact", - "run_collection", -}) + +_APPLICATION_EXPORTS = frozenset( + { + "CollectorFailureSnapshot", + "CollectorSnapshot", + "RunSnapshot", + "load_configuration", + "open_artifact", + "plan_run", + "query_run", + "read_artifact", + "run_collection", + } +) def __getattr__(name: str): @@ -47,15 +56,15 @@ def __getattr__(name: str): __all__ = [ "CONTRACT_VERSION", "Capability", - "Collector", "CollectionEstimate", + "Collector", + "CollectorFailureSnapshot", "CollectorMetadata", "CollectorResult", - "CollectorFailureSnapshot", "CollectorSnapshot", "CoreCollector", - "EvidenceKind", "EstimatedCost", + "EvidenceKind", "NetworkAccess", "OutputPolicy", "PluginCollector", @@ -72,5 +81,5 @@ def __getattr__(name: str): "plan_run", "query_run", "read_artifact", - "run_collection" + "run_collection", ] diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 525c3701..7dccf879 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -5,11 +5,11 @@ import sys from pathlib import Path +from logicytics.terminal import terminal_lifecycle from logicytics.virtual_environment import ( is_running_in_virtual_environment, render_virtual_environment_error, ) -from logicytics.terminal import terminal_lifecycle __all__ = ["CLI", "cli_methods", "main"] diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index dc98eb54..4e4d0320 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -18,7 +18,13 @@ from logicytics.module.discovery import preflight from logicytics.module.environment import inspect_environment from logicytics.module.errors import LogicyticsError -from logicytics.module.interaction import load_history, match_flag, record_match, usage_statistics, write_usage_graph +from logicytics.module.interaction import ( + load_history, + match_flag, + record_match, + usage_statistics, + write_usage_graph, +) from logicytics.module.logging import ApplicationLogger, HumanArgumentParser, get_application_logger from logicytics.module.maintenance import ( build_manifest, @@ -54,9 +60,9 @@ class CLI: @staticmethod def render_preflight( - logger: object, - validation: dict[str, list[dict[str, object]]], - sysinternals: dict[str, str], + logger: object, + validation: dict[str, list[dict[str, object]]], + sysinternals: dict[str, str], ) -> None: """Present validated collectors and diagnostics without exposing internal JSON.""" valid = validation["valid"] @@ -74,9 +80,7 @@ def render_preflight( for item in (*invalid, *quarantined): diagnostics = item.get("diagnostics", []) details = "; ".join( - str(diagnostic.get("message", "invalid collector")) - for diagnostic in diagnostics - if isinstance(diagnostic, dict) + str(diagnostic.get("message", "invalid collector")) for diagnostic in diagnostics if isinstance(diagnostic, dict) ) logger.event( "ERROR" if item in invalid else "WARNING", @@ -92,15 +96,12 @@ def project_root() -> Path: @staticmethod def request( - arguments: argparse.Namespace, - default_workers: int, - configured_blocked_capabilities: tuple[Capability, ...] = (), + arguments: argparse.Namespace, + default_workers: int, + configured_blocked_capabilities: tuple[Capability, ...] = (), ) -> RunRequest: """Build an immutable run request while enforcing mode and rerun conflicts.""" - legacy_flags = { - flag: getattr(arguments, flag, False) - for flag in LEGACY_MODE_ALIASES - } + legacy_flags = {flag: getattr(arguments, flag, False) for flag in LEGACY_MODE_ALIASES} mode = resolve_execution_mode(getattr(arguments, "mode", None), legacy_flags) explicit_profile = getattr(arguments, "profile", None) @@ -116,32 +117,20 @@ def request( if mode is not None: if explicit_sequential and mode.strategy is ExecutionStrategy.PARALLEL: if legacy_flags["threaded"]: - raise ValueError( - "sequential execution conflicts with legacy --threaded mode" - ) - raise ValueError( - f"{mode.name} mode requires configured parallel execution" - ) + raise ValueError("sequential execution conflicts with legacy --threaded mode") + raise ValueError(f"{mode.name} mode requires configured parallel execution") if explicit_parallel and mode.strategy is ExecutionStrategy.SEQUENTIAL: if legacy_flags["performance_check"] or legacy_flags["default_mode"]: - raise ValueError( - "parallel execution conflicts with sequential performance/default mode" - ) + raise ValueError("parallel execution conflicts with sequential performance/default mode") raise ValueError(f"{mode.name} mode requires sequential execution") - sequential = explicit_sequential or ( - mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL - ) - parallel = explicit_parallel or ( - mode is not None and mode.strategy is ExecutionStrategy.PARALLEL - ) + sequential = explicit_sequential or (mode is not None and mode.strategy is ExecutionStrategy.SEQUENTIAL) + parallel = explicit_parallel or (mode is not None and mode.strategy is ExecutionStrategy.PARALLEL) performance_check = mode.performance_check if mode is not None else False - enable_mods = getattr(arguments, "mods", False) or ( - mode.enable_mods if mode is not None else False - ) + enable_mods = getattr(arguments, "mods", False) or (mode.enable_mods if mode is not None else False) requested_workers = getattr(arguments, "workers", None) @@ -151,9 +140,7 @@ def request( worker_count = 1 if sequential else requested_workers or default_workers if parallel and worker_count < 2: - raise ValueError( - "parallel execution requires at least two configured workers" - ) + raise ValueError("parallel execution requires at least two configured workers") parent_run_id: str | None = None @@ -161,56 +148,37 @@ def request( if rerun_value is not None: if not isinstance(rerun_value, (str, os.PathLike)): - raise TypeError( - f"rerun_from must be a path-like value, got {type(rerun_value).__name__}" - ) + raise TypeError(f"rerun_from must be a path-like value, got {type(rerun_value).__name__}") rerun_path = Path(rerun_value) if not arguments.include: - raise ValueError( - "--rerun-from requires at least one explicit --include collector ID" - ) + raise ValueError("--rerun-from requires at least one explicit --include collector ID") - manifest_path = ( - rerun_path / "manifest.json" - if rerun_path.is_dir() - else rerun_path - ) + manifest_path = rerun_path / "manifest.json" if rerun_path.is_dir() else rerun_path try: - previous = json.loads( - manifest_path.read_text(encoding="utf-8") - ) + previous = json.loads(manifest_path.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - raise ValueError( - f"original run manifest cannot be loaded: {error}" - ) from error + raise ValueError(f"original run manifest cannot be loaded: {error}") from error if not isinstance(previous, dict) or not isinstance( - previous.get("run_id"), - str, + previous.get("run_id"), + str, ): - raise ValueError( - "original run manifest must contain a valid run_id" - ) + raise ValueError("original run manifest must contain a valid run_id") manifest_schema_version = previous.get("manifest_schema_version") if ( - not isinstance(manifest_schema_version, int) - or isinstance(manifest_schema_version, bool) - or manifest_schema_version != MANIFEST_SCHEMA_VERSION + not isinstance(manifest_schema_version, int) + or isinstance(manifest_schema_version, bool) + or manifest_schema_version != MANIFEST_SCHEMA_VERSION ): - schema_version_display = ( - "None" - if manifest_schema_version is None - else repr(manifest_schema_version) - ) + schema_version_display = "None" if manifest_schema_version is None else repr(manifest_schema_version) raise ValueError( - f"original run manifest uses unsupported schema_version " - f"{schema_version_display}; expected {MANIFEST_SCHEMA_VERSION}" + f"original run manifest uses unsupported schema_version {schema_version_display}; expected {MANIFEST_SCHEMA_VERSION}" ) if previous.get("status") not in { @@ -219,29 +187,17 @@ def request( "failed", "cancelled", }: - raise ValueError( - "original run manifest must describe a finalized run" - ) + raise ValueError("original run manifest must describe a finalized run") resolved = previous.get("resolved_plan") - if not isinstance(resolved, list) or not all( - isinstance(item, str) - for item in resolved - ): - raise ValueError( - "original run manifest must contain a valid resolved_plan" - ) + if not isinstance(resolved, list) or not all(isinstance(item, str) for item in resolved): + raise ValueError("original run manifest must contain a valid resolved_plan") - unknown = sorted( - set(arguments.include) - set(resolved) - ) + unknown = sorted(set(arguments.include) - set(resolved)) if unknown: - raise ValueError( - "rerun collectors were not present in the original run: " - f"{', '.join(unknown)}" - ) + raise ValueError(f"rerun collectors were not present in the original run: {', '.join(unknown)}") parent_run_id = previous["run_id"] @@ -251,30 +207,13 @@ def request( plugins_enabled = bool(getattr(arguments, "plugins", False)) mods_enabled = bool(getattr(arguments, "mods", False)) - if selection_only and ( - include_arguments - or exclude_arguments - or arguments.profile is not None - or plugins_enabled - or mods_enabled - ): - raise ValueError( - "collector execution cannot combine its ID with profile or selection flags" - ) + if selection_only and (include_arguments or exclude_arguments or arguments.profile is not None or plugins_enabled or mods_enabled): + raise ValueError("collector execution cannot combine its ID with profile or selection flags") - includes = ( - (getattr(arguments, "collector_id"),) - if selection_only - else include_arguments - ) + includes = (arguments.collector_id,) if selection_only else include_arguments - requested_blocked_capabilities = tuple( - Capability(value) - for value in getattr(arguments, "block_capability", ()) - ) - blocked_capabilities = tuple(dict.fromkeys( - (*configured_blocked_capabilities, *requested_blocked_capabilities) - )) + requested_blocked_capabilities = tuple(Capability(value) for value in getattr(arguments, "block_capability", ())) + blocked_capabilities = tuple(dict.fromkeys((*configured_blocked_capabilities, *requested_blocked_capabilities))) return RunRequest( profile=profile, @@ -292,11 +231,7 @@ def request( blocked_capabilities=blocked_capabilities, performance_check=performance_check, rerun_from=parent_run_id, - output_policy=( - OutputPolicy.MANIFEST_ONLY - if getattr(arguments, "no_package", False) - else OutputPolicy.PACKAGE - ), + output_policy=(OutputPolicy.MANIFEST_ONLY if getattr(arguments, "no_package", False) else OutputPolicy.PACKAGE), post_run_action=( PostRunAction.REBOOT if getattr(arguments, "reboot", False) @@ -315,11 +250,17 @@ def parser() -> argparse.ArgumentParser: type=Path, help="Path to the authoritative Logicytics YAML configuration file", ) - parser.add_argument("--usage", action="store_true", - help="Show local interaction statistics and create a usage graph.") + parser.add_argument( + "--usage", + action="store_true", + help="Show local interaction statistics and create a usage graph.", + ) parser.add_argument("--modes", action="store_true", help="Show the typed execution-mode inclusion matrix.") - parser.add_argument("--match", metavar="TEXT", - help="Suggest the closest documented action for natural-language input.") + parser.add_argument( + "--match", + metavar="TEXT", + help="Suggest the closest documented action for natural-language input.", + ) subcommands = parser.add_subparsers(dest="command", parser_class=HumanArgumentParser) for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") @@ -330,23 +271,33 @@ def parser() -> argparse.ArgumentParser: help="Named built-in collector membership and access policy.", ) subparser.add_argument( - "--include", action="append", default=[], metavar="COLLECTOR_ID", + "--include", + action="append", + default=[], + metavar="COLLECTOR_ID", help="Include a collector by exact dotted ID; repeat for multiple collectors.", ) subparser.add_argument( - "--exclude", action="append", default=[], metavar="COLLECTOR_ID", + "--exclude", + action="append", + default=[], + metavar="COLLECTOR_ID", help="Exclude a collector by exact dotted ID; repeat for multiple collectors.", ) subparser.add_argument( - "--plugins", action="store_true", + "--plugins", + action="store_true", help="Enable all valid opt-in plugin collectors for the selected profile.", ) subparser.add_argument( - "--mods", action="store_true", + "--mods", + action="store_true", help="Enable valid sidecar-declared scripts from the MODS directory.", ) subparser.add_argument( - "--workers", type=int, metavar="COUNT", + "--workers", + type=int, + metavar="COUNT", help="Bound concurrent isolated workers to this positive count.", ) subparser.add_argument( @@ -379,14 +330,24 @@ def parser() -> argparse.ArgumentParser: choices=tuple(EXECUTION_MODES), help="Select one user-facing typed execution mode.", ) - mode.add_argument("--default", dest="default_mode", action="store_true", - help="Run the standard built-in profile.") - mode.add_argument("--threaded", action="store_true", - help="Run the standard built-in profile with configured parallel workers.") + mode.add_argument( + "--default", + dest="default_mode", + action="store_true", + help="Run the standard built-in profile.", + ) + mode.add_argument( + "--threaded", + action="store_true", + help="Run the standard built-in profile with configured parallel workers.", + ) mode.add_argument("--minimal", action="store_true", help="Run the minimal built-in profile.") mode.add_argument("--depth", action="store_true", help="Run the deep built-in profile.") - mode.add_argument("--modded", action="store_true", - help="Run the standard profile plus all valid MODS scripts.") + mode.add_argument( + "--modded", + action="store_true", + help="Run the standard profile plus all valid MODS scripts.", + ) mode.add_argument( "--performance-check", action="store_true", @@ -436,8 +397,11 @@ def parser() -> argparse.ArgumentParser: help="Pause at the final status so an interactive command window remains visible.", ) if command == "update": - subparser.add_argument("--apply", action="store_true", - help="Explicitly run git pull after repository checks.") + subparser.add_argument( + "--apply", + action="store_true", + help="Explicitly run git pull after repository checks.", + ) subparser.add_argument( "--launch-action", choices=("preflight", "debug", "dev"), @@ -457,25 +421,16 @@ def parser() -> argparse.ArgumentParser: subparser.add_argument( "--write-manifest", action="store_true", - help=( - "Write the reviewed local integrity manifest; requires --next-version " - "unless interactive." - ), + help=("Write the reviewed local integrity manifest; requires --next-version unless interactive."), ) subparser.add_argument( "--next-version", - help=( - "Semantic version to validate and record in a newly written " - "integrity manifest." - ), + help=("Semantic version to validate and record in a newly written integrity manifest."), ) subparser.add_argument( "--interactive", action="store_true", - help=( - "Show contribution checks and prompt before changing the local " - "integrity manifest." - ), + help=("Show contribution checks and prompt before changing the local integrity manifest."), ) return parser @@ -506,12 +461,12 @@ def launch_action_window(root: Path, action: str) -> int: return process.pid def run_developer_action( - self, - root: Path, - configuration: AppConfig, - arguments: argparse.Namespace, - debug_logs: Path, - logger: object, + self, + root: Path, + configuration: AppConfig, + arguments: argparse.Namespace, + debug_logs: Path, + logger: object, ) -> int: """Run read-only contribution checks and an explicitly confirmed manifest update.""" settings = configuration.maintenance @@ -550,40 +505,36 @@ def run_developer_action( logger.box( "Contribution and repository organization checks", ( - *(f"{name.title()}: {len(comparison[name])}" for name in ( - "missing", "modified", "extra", "unchanged", - )), + *( + f"{name.title()}: {len(comparison[name])}" + for name in ( + "missing", + "modified", + "extra", + "unchanged", + ) + ), *(f"{name.replace('_', ' ').title()}: {len(checks[name])}" for name in organization_checks), ), ) if next_version is None: current_version = local_version(root) - entered = input( - f"Next semantic version [{current_version}]: " - ).strip() + entered = input(f"Next semantic version [{current_version}]: ").strip() next_version = entered or current_version - answer = input( - "Write the reviewed integrity manifest? [y/N]: " - ).strip().casefold() + answer = input("Write the reviewed integrity manifest? [y/N]: ").strip().casefold() write_requested = answer in {"y", "yes"} if arguments.write_manifest and next_version is None: - raise ValueError( - "--write-manifest requires --next-version unless " - "--interactive is used" - ) + raise ValueError("--write-manifest requires --next-version unless --interactive is used") manifest_path: str | None = None if write_requested: if next_version is None: - raise ValueError( - "a semantic next version is required to write " - "the integrity manifest" - ) + raise ValueError("a semantic next version is required to write the integrity manifest") manifest = build_manifest(root, settings, next_version) written_manifest = write_local_manifest( @@ -598,15 +549,22 @@ def run_developer_action( "checks": checks, "comparison": comparison, "current_version": local_version(root), - "existing_manifest_version": ( - existing.version if existing else None - ), + "existing_manifest_version": (existing.version if existing else None), "manifest_written": manifest_path, "next_version": next_version, } development_path = debug_logs / "development.json" self.write_json(development_path, payload) + organization_issues = sum( + len(checks[name]) + for name in ( + "naming_violations", + "misplaced_python", + "missing_module_docstrings", + "crowded_modules", + ) + ) logger.box( "Development checks", ( @@ -614,7 +572,7 @@ def run_developer_action( f"Modified files: {len(comparison['modified'])}", f"Extra files: {len(comparison['extra'])}", f"Unchanged files: {len(comparison['unchanged'])}", - f"Organization issues: {sum(len(checks[name]) for name in ('naming_violations', 'misplaced_python', 'missing_module_docstrings', 'crowded_modules'))}", + f"Organization issues: {organization_issues}", f"Manifest written: {'yes' if manifest_path else 'no'}", f"Manifest path: {manifest_path or 'none'}", f"Diagnostic report: {development_path}", @@ -645,14 +603,10 @@ def main(argv: list[str] | None = None) -> int: ) if standalone_actions > 1: - cli_parser.error( - "--usage, --match, and --modes are mutually exclusive" - ) + cli_parser.error("--usage, --match, and --modes are mutually exclusive") if arguments.command is not None and standalone_actions: - cli_parser.error( - "--usage, --match, and --modes are standalone actions" - ) + cli_parser.error("--usage, --match, and --modes are standalone actions") if arguments.usage: arguments.command = "usage" @@ -672,9 +626,7 @@ def main(argv: list[str] | None = None) -> int: try: configuration = load_config(root, arguments.config) - layout = ensure_output_layout( - configuration.runtime.output_root - ) + layout = ensure_output_layout(configuration.runtime.output_root) application_logger = get_application_logger( layout.application_log, @@ -691,10 +643,10 @@ def main(argv: list[str] | None = None) -> int: command_started_at = started_at def finish_command( - exit_code: int, - *, - status: str | None = None, - **fields: int | float | str, + exit_code: int, + *, + status: str | None = None, + **fields: float | str, ) -> int: """Record one command's terminal status and elapsed time.""" application_logger.event( @@ -709,19 +661,14 @@ def finish_command( ) return exit_code - history_path = ( - configuration.runtime.output_root - / "interaction_history.json.gz" - ) + history_path = configuration.runtime.output_root / "interaction_history.json.gz" if arguments.command == "match": history = load_history(history_path) match = match_flag( arguments.match, - threshold=( - configuration.interaction.similarity_threshold - ), + threshold=(configuration.interaction.similarity_threshold), model_name=configuration.interaction.model_name, history=history, ) @@ -738,11 +685,7 @@ def finish_command( f"Match source: {match.source.replace('_', ' ')}", f"History persisted: {'yes' if configuration.interaction.history_enabled else 'no'}", *( - ( - "Model: " - f"{match.model_name} " - f"(threshold {configuration.interaction.similarity_threshold:.1%})", - ) + (f"Model: {match.model_name} (threshold {configuration.interaction.similarity_threshold:.1%})",) if configuration.interaction.model_debug else () ), @@ -757,22 +700,16 @@ def finish_command( ) if arguments.command == "usage": - statistics = usage_statistics( - load_history(history_path) - ) + statistics = usage_statistics(load_history(history_path)) graph_path = write_usage_graph( - configuration.runtime.output_root - / "flag_usage.svg", + configuration.runtime.output_root / "flag_usage.svg", statistics, ) frequencies = statistics.get("per_flag_frequency", {}) frequency_rows = ( - tuple( - f"{flag}: {count}" - for flag, count in sorted(frequencies.items()) - ) + tuple(f"{flag}: {count}" for flag, count in sorted(frequencies.items())) if isinstance(frequencies, dict) and frequencies else ("none",) ) @@ -813,18 +750,13 @@ def finish_command( purpose="mode_matrix", ) - payload = mode_matrix( - (*report.valid, *report.invalid) - ) + payload = mode_matrix((*report.valid, *report.invalid)) matrix_path = layout.debug_logs / "modes.json" cli_methods.write_json(matrix_path, payload) mode_rows = [] for item in payload["modes"]: aliases = ", ".join(item["legacy_aliases"]) or "none" - mode_rows.append( - f"{item['name']}: {item['description']} " - f"({len(item['collector_ids'])} collectors; aliases: {aliases})" - ) + mode_rows.append(f"{item['name']}: {item['description']} ({len(item['collector_ids'])} collectors; aliases: {aliases})") application_logger.box( "Execution modes", ( @@ -880,29 +812,18 @@ def finish_command( if arguments.command == "debug": payload: dict[str, object] = { - "configuration": ( - configuration.to_manifest_dict() - ), + "configuration": (configuration.to_manifest_dict()), "environment": inspect_environment().to_dict(), "python": { "executable": sys.executable, - "implementation": ( - platform.python_implementation() - ), + "implementation": (platform.python_implementation()), "version": platform.python_version(), "prefix": sys.prefix, - "virtual_environment": ( - sys.prefix != sys.base_prefix - ), - "psutil_available": ( - importlib.util.find_spec("psutil") - is not None - ), + "virtual_environment": (sys.prefix != sys.base_prefix), + "psutil_available": (importlib.util.find_spec("psutil") is not None), "cpu_count": os.cpu_count(), }, - "sysinternals": ( - ensure_sysinternals(root, configuration.maintenance).to_dict() - ), + "sysinternals": (ensure_sysinternals(root, configuration.maintenance).to_dict()), "preflight": { "valid_collectors": len(report.valid), "invalid_collectors": len(report.invalid), @@ -913,10 +834,7 @@ def finish_command( ), } - debug_path = ( - layout.debug_logs - / "debug.json" - ) + debug_path = layout.debug_logs / "debug.json" payload["debug_log"] = str(debug_path) @@ -944,13 +862,8 @@ def finish_command( ) if arguments.command == "update": - if arguments.new_window != ( - arguments.launch_action is not None - ): - raise ValueError( - "--new-window and --launch-action " - "must be provided together" - ) + if arguments.new_window != (arguments.launch_action is not None): + raise ValueError("--new-window and --launch-action must be provided together") git = process_adapter.run( ["git", "--version"], @@ -963,9 +876,7 @@ def finish_command( payload: dict[str, object] = { "git_available": git.returncode == 0, - "git_version": ( - git.stdout.strip() or None - ), + "git_version": (git.stdout.strip() or None), "is_repository": is_repository, "applied": False, } @@ -1011,10 +922,7 @@ def finish_command( } ) - update_succeeded = ( - not arguments.apply - or pull_returncode == 0 - ) + update_succeeded = not arguments.apply or pull_returncode == 0 if arguments.new_window and update_succeeded: launched_process_id = cli_methods.launch_action_window( @@ -1022,12 +930,8 @@ def finish_command( arguments.launch_action, ) - payload["launched_action"] = ( - arguments.launch_action - ) - payload["launched_process_id"] = ( - launched_process_id - ) + payload["launched_action"] = arguments.launch_action + payload["launched_process_id"] = launched_process_id update_path = layout.debug_logs / "update.json" cli_methods.write_json(update_path, payload) @@ -1038,9 +942,8 @@ def finish_command( f"Update status: {'succeeded' if update_succeeded else 'failed'}", ] if arguments.apply: - update_lines.append( - f"Git pull exit code: {pull_returncode if pull_returncode is not None else 'none'}" - ) + pull_exit_code = pull_returncode if pull_returncode is not None else "none" + update_lines.append(f"Git pull exit code: {pull_exit_code}") if payload.get("stdout"): update_lines.extend(("Git output:", *str(payload["stdout"]).splitlines())) if payload.get("stderr"): @@ -1106,11 +1009,8 @@ def finish_command( if arguments.command == "plan": application_logger.box( "Collection plan", - tuple( - candidate.metadata.id - for candidate in plan.collectors - if candidate.metadata is not None - ) or ("No collectors were selected.",), + tuple(candidate.metadata.id for candidate in plan.collectors if candidate.metadata is not None) + or ("No collectors were selected.",), ) application_logger.event( "INFO", @@ -1129,17 +1029,10 @@ def finish_command( result_lines = ["Collectors:"] for record in outcome.manifest.collectors: - duration = ( - "not-started" - if record.duration_seconds is None - else f"{record.duration_seconds:.3f}" - ) + duration = "not-started" if record.duration_seconds is None else f"{record.duration_seconds:.3f}" result_lines.append( - f"- {record.id} " - f"status={record.status} " - f"duration_seconds={duration} " - f"summary={record.summary or 'not-finished'}" + f"- {record.id} status={record.status} duration_seconds={duration} summary={record.summary or 'not-finished'}" ) if record.failure is not None: @@ -1155,41 +1048,18 @@ def finish_command( ) if plan.request.performance_check: - performance_path = ( - outcome.run_directory - / "logs" - / "performance.json" - ) + performance_path = outcome.run_directory / "logs" / "performance.json" - result_lines.append( - f"Performance: {performance_path}" - ) + result_lines.append(f"Performance: {performance_path}") + + if outcome.manifest.package and "path" in outcome.manifest.package: + package_sha_path = outcome.manifest.package.get("sha256_path", "unavailable") + result_lines.extend((f"Package: {outcome.manifest.package['path']}\nSHA-256: {package_sha_path}").splitlines()) - if ( - outcome.manifest.package - and "path" in outcome.manifest.package - ): - result_lines.extend(( - f"Package: " - f"{outcome.manifest.package['path']}\n" - f"SHA-256: " - f"{outcome.manifest.package.get( - 'sha256_path', - 'unavailable', - )}" - ).splitlines()) - - result_lines.extend(( - f"Run: {outcome.manifest_path}\n" - f"Status: {outcome.manifest.status.value}" - ).splitlines()) + result_lines.extend((f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}").splitlines()) application_logger.box("Collection result", result_lines) - exit_code = ( - 0 - if outcome.manifest.status.value == "succeeded" - else 1 - ) + exit_code = 0 if outcome.manifest.status.value == "succeeded" else 1 if arguments.interactive: try: @@ -1205,10 +1075,10 @@ def finish_command( ) except ( - LogicyticsError, - OSError, - PermissionError, - ValueError, + LogicyticsError, + OSError, + PermissionError, + ValueError, ) as error: if application_logger is not None: with contextlib.suppress(OSError): diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 0a1fc5b2..0daf4962 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -2,7 +2,6 @@ from __future__ import annotations -import argparse import sys import venv from pathlib import Path diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index 02e9c396..c0277ef0 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -2,17 +2,16 @@ from __future__ import annotations -import argparse import io import sys import unittest from pathlib import Path +from logicytics.terminal import terminal_lifecycle from logicytics.virtual_environment import ( is_running_in_virtual_environment, render_virtual_environment_error, ) -from logicytics.terminal import terminal_lifecycle def project_root() -> Path: diff --git a/logicytics/contracts.py b/logicytics/contracts.py index be84e12b..dae2ab0a 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -4,12 +4,13 @@ import re from abc import ABC, abstractmethod +from collections.abc import Mapping from dataclasses import asdict, dataclass, field from datetime import datetime from enum import StrEnum from math import isfinite from pathlib import Path -from typing import Any, Mapping +from typing import Any CONTRACT_VERSION = "4.0" _CUSTOM_SPECIALTY = re.compile(r"^[a-z][a-z0-9_]{1,63}$") @@ -191,7 +192,8 @@ def __post_init__(self) -> None: if not _CONTRACT_VERSION.fullmatch(self.minimum_contract_version): raise ValueError("metadata minimum_contract_version has an invalid schema") if not isinstance(self.specialty, Specialty) and ( - not isinstance(self.specialty, str) or not _CUSTOM_SPECIALTY.fullmatch(self.specialty)): + not isinstance(self.specialty, str) or not _CUSTOM_SPECIALTY.fullmatch(self.specialty) + ): raise ValueError("metadata specialty has an invalid schema") self._validate_labels("supported_platforms", self.supported_platforms, require_value=True) self._validate_labels("sensitive_data_categories", self.sensitive_data_categories) @@ -200,17 +202,17 @@ def __post_init__(self) -> None: primary_specialty = self.specialty.value if isinstance(self.specialty, Specialty) else self.specialty if primary_specialty in self.secondary_categories: raise ValueError("metadata secondary_categories must not repeat the primary specialty") - if not isinstance(self.output_media_types, tuple) or not self.output_media_types or not all( - isinstance(media_type, str) and _MEDIA_TYPE.fullmatch(media_type) - for media_type in self.output_media_types + if ( + not isinstance(self.output_media_types, tuple) + or not self.output_media_types + or not all(isinstance(media_type, str) and _MEDIA_TYPE.fullmatch(media_type) for media_type in self.output_media_types) ): raise ValueError("metadata output_media_types must be a non-empty tuple of MIME types") if len(set(self.output_media_types)) != len(self.output_media_types): raise ValueError("metadata output_media_types must not contain duplicates") if self.sensitive_data_categories and {"standard", "minimal"}.intersection(self.default_profiles): raise ValueError("sensitive collectors must not belong to standard or minimal profiles") - if not isinstance(self.capabilities, tuple) or not all( - isinstance(capability, Capability) for capability in self.capabilities): + if not isinstance(self.capabilities, tuple) or not all(isinstance(capability, Capability) for capability in self.capabilities): raise ValueError("metadata capabilities must be a tuple of Capability values") if not isinstance(self.privilege_level, PrivilegeLevel): raise ValueError("metadata privilege_level must be a PrivilegeLevel value") @@ -224,33 +226,36 @@ def __post_init__(self) -> None: if Capability.NETWORK in self.capabilities and self.network_access is NetworkAccess.NONE: raise ValueError("metadata network_access must declare local or remote access") if not isinstance(self.dependencies, tuple) or not all( - isinstance(dependency, str) and _COLLECTOR_ID.fullmatch(dependency) - for dependency in self.dependencies): + isinstance(dependency, str) and _COLLECTOR_ID.fullmatch(dependency) for dependency in self.dependencies + ): raise ValueError("metadata dependencies must be collector IDs") if len(set(self.dependencies)) != len(self.dependencies) or self.id in self.dependencies: raise ValueError("metadata dependencies must be unique and cannot include the collector itself") if self.maximum_artifact_bytes is None: object.__setattr__(self, "maximum_artifact_bytes", self.maximum_output_bytes) for name in ( - "timeout_seconds", - "maximum_memory_bytes", - "maximum_output_bytes", - "maximum_artifact_bytes", - "maximum_artifact_files", + "timeout_seconds", + "maximum_memory_bytes", + "maximum_output_bytes", + "maximum_artifact_bytes", + "maximum_artifact_files", ): value = getattr(self, name) if not isinstance(value, int) or isinstance(value, bool) or value < 1: raise ValueError(f"metadata {name} must be a positive integer") if self.maximum_artifact_bytes > self.maximum_output_bytes: raise ValueError("metadata maximum_artifact_bytes must not exceed maximum_output_bytes") - if not isinstance(self.maximum_retries, int) or isinstance(self.maximum_retries, bool) or not ( - 0 <= self.maximum_retries <= 3 - ): + if not isinstance(self.maximum_retries, int) or isinstance(self.maximum_retries, bool) or not (0 <= self.maximum_retries <= 3): raise ValueError("metadata maximum_retries must be an integer from 0 to 3") - if not isinstance(self.retry_delay_seconds, (int, float)) or isinstance( + if ( + not isinstance(self.retry_delay_seconds, (int, float)) + or isinstance( self.retry_delay_seconds, bool, - ) or not isfinite(self.retry_delay_seconds) or not 0 <= self.retry_delay_seconds <= 30: + ) + or not isfinite(self.retry_delay_seconds) + or not 0 <= self.retry_delay_seconds <= 30 + ): raise ValueError("metadata retry_delay_seconds must be a number from 0 to 30") if not isinstance(self.parallel_safe, bool): raise ValueError("metadata parallel_safe must be boolean") @@ -260,8 +265,11 @@ def __post_init__(self) -> None: @staticmethod def _validate_labels(name: str, values: tuple[str, ...], *, require_value: bool = False) -> None: """Require unique lower-snake-case labels for selector-like metadata fields.""" - if not isinstance(values, tuple) or (require_value and not values) or not all( - isinstance(value, str) and _LABEL.fullmatch(value) for value in values): + if ( + not isinstance(values, tuple) + or (require_value and not values) + or not all(isinstance(value, str) and _LABEL.fullmatch(value) for value in values) + ): raise ValueError(f"metadata {name} must be a tuple of lowercase labels") if len(set(values)) != len(values): raise ValueError(f"metadata {name} must not contain duplicates") @@ -278,15 +286,14 @@ def to_dict(self) -> dict[str, Any]: return data @classmethod - def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = False) -> "CollectorMetadata": + def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = False) -> CollectorMetadata: """Build metadata returned by an isolated validation worker.""" values = dict(data) specialty = values["specialty"] try: values["specialty"] = Specialty(specialty) except ValueError: - if not allow_custom_specialty or not isinstance(specialty, str) or not _CUSTOM_SPECIALTY.fullmatch( - specialty): + if not allow_custom_specialty or not isinstance(specialty, str) or not _CUSTOM_SPECIALTY.fullmatch(specialty): raise ValueError("collector specialty is unsupported") values["specialty"] = specialty values["capabilities"] = tuple(Capability(capability) for capability in values.get("capabilities", ())) @@ -295,12 +302,12 @@ def from_dict(cls, data: Mapping[str, Any], *, allow_custom_specialty: bool = Fa values["network_access"] = NetworkAccess(values.get("network_access", NetworkAccess.NONE)) values["estimated_cost"] = EstimatedCost(values.get("estimated_cost", EstimatedCost.LOW)) for field_name in ( - "supported_platforms", - "sensitive_data_categories", - "secondary_categories", - "output_media_types", - "dependencies", - "default_profiles", + "supported_platforms", + "sensitive_data_categories", + "secondary_categories", + "output_media_types", + "dependencies", + "default_profiles", ): values[field_name] = tuple(values.get(field_name, ())) return cls(**values) @@ -350,14 +357,14 @@ def __post_init__(self) -> None: if collected_at.tzinfo is None: raise ValueError("artifact collected_at must include a timezone") if not isinstance(self.transformations, tuple) or any( - not isinstance(step, str) or not step.strip() for step in self.transformations + not isinstance(step, str) or not step.strip() for step in self.transformations ): raise ValueError("artifact transformations must be a tuple of non-empty strings") if not isinstance(self.evidence_kind, EvidenceKind): raise ValueError("artifact evidence_kind must be an EvidenceKind value") @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> "Artifact": + def from_dict(cls, data: Mapping[str, Any]) -> Artifact: """Reconstruct a strict artifact after a JSON or worker boundary.""" values = dict(data) transformations = values.get("transformations") @@ -422,57 +429,56 @@ def __post_init__(self) -> None: artifact_paths = tuple(item.relative_path for item in self.artifacts) if len(set(artifact_ids)) != len(artifact_ids) or len(set(artifact_paths)) != len(artifact_paths): raise ValueError("collector result artifacts must have unique IDs and paths") - if not isinstance(self.errors, tuple) or not all( - isinstance(error, str) and error.strip() for error in self.errors - ): + if not isinstance(self.errors, tuple) or not all(isinstance(error, str) and error.strip() for error in self.errors): raise ValueError("collector result errors must be a tuple of non-empty strings") if not isinstance(self.metrics, Mapping) or not all( - isinstance(name, str) and name.strip() - and isinstance(value, (int, float, str)) - and not isinstance(value, bool) - and (not isinstance(value, float) or isfinite(value)) - for name, value in self.metrics.items() + isinstance(name, str) + and name.strip() + and isinstance(value, (int, float, str)) + and not isinstance(value, bool) + and (not isinstance(value, float) or isfinite(value)) + for name, value in self.metrics.items() ): raise ValueError("collector result metrics must contain finite scalar values") @classmethod - def succeeded(cls, summary: str, artifacts: tuple[Artifact, ...] = ()) -> "CollectorResult": + def succeeded(cls, summary: str, artifacts: tuple[Artifact, ...] = ()) -> CollectorResult: """Return a successful result containing any registered evidence.""" return cls(CollectorStatus.SUCCEEDED, summary, artifacts) @classmethod def partial( - cls, - summary: str, - artifacts: tuple[Artifact, ...] = (), - *, - errors: tuple[str, ...] = (), - ) -> "CollectorResult": + cls, + summary: str, + artifacts: tuple[Artifact, ...] = (), + *, + errors: tuple[str, ...] = (), + ) -> CollectorResult: """Return an explicitly incomplete result while preserving registered evidence.""" return cls(CollectorStatus.PARTIAL, summary, artifacts, errors=errors) @classmethod - def skipped(cls, summary: str, *, errors: tuple[str, ...] = ()) -> "CollectorResult": + def skipped(cls, summary: str, *, errors: tuple[str, ...] = ()) -> CollectorResult: """Return an explicit prerequisite or policy skip.""" return cls(CollectorStatus.SKIPPED, summary, errors=errors) @classmethod def cancelled( - cls, - summary: str, - artifacts: tuple[Artifact, ...] = (), - ) -> "CollectorResult": + cls, + summary: str, + artifacts: tuple[Artifact, ...] = (), + ) -> CollectorResult: """Return explicit cancellation while retaining already registered evidence.""" return cls(CollectorStatus.CANCELLED, summary, artifacts) @classmethod def failed( - cls, - summary: str, - *, - errors: tuple[str, ...] = (), - artifacts: tuple[Artifact, ...] = (), - ) -> "CollectorResult": + cls, + summary: str, + *, + errors: tuple[str, ...] = (), + artifacts: tuple[Artifact, ...] = (), + ) -> CollectorResult: """Return explicit failure while retaining already registered evidence.""" return cls(CollectorStatus.FAILED, summary, artifacts, errors=errors) @@ -505,41 +511,40 @@ def __post_init__(self) -> None: for name in ("include", "exclude"): selections = getattr(self, name) if not isinstance(selections, tuple) or not all( - isinstance(collector_id, str) and _COLLECTOR_ID.fullmatch(collector_id) - for collector_id in selections + isinstance(collector_id, str) and _COLLECTOR_ID.fullmatch(collector_id) for collector_id in selections ): raise ValueError(f"request {name} must be a tuple of collector IDs") if len(set(selections)) != len(selections): raise ValueError(f"request {name} must not contain duplicate collector IDs") if set(self.include).intersection(self.exclude): raise ValueError("request include and exclude selections must not overlap") - if self.rerun_from is not None and ( - not isinstance(self.rerun_from, str) or not _RUN_ID.fullmatch(self.rerun_from) - ): + if self.rerun_from is not None and (not isinstance(self.rerun_from, str) or not _RUN_ID.fullmatch(self.rerun_from)): raise ValueError("request rerun_from must be a valid original run ID") if self.rerun_from is not None and not self.include: raise ValueError("request rerun_from requires explicit included collector IDs") for name in ( - "selection_only", "enable_plugins", "enable_mods", - "acknowledge_authorization", "performance_check", + "selection_only", + "enable_plugins", + "enable_mods", + "acknowledge_authorization", + "performance_check", ): if not isinstance(getattr(self, name), bool): raise ValueError(f"request {name} must be boolean") if self.selection_only and not self.include: raise ValueError("request selection_only requires explicit included collector IDs") - if not isinstance(self.max_workers, int) or isinstance(self.max_workers, - bool) or not 1 <= self.max_workers <= 64: + if not isinstance(self.max_workers, int) or isinstance(self.max_workers, bool) or not 1 <= self.max_workers <= 64: raise ValueError("request max_workers must be an integer from 1 to 64") if self.performance_check and self.max_workers != 1: raise ValueError("request performance_check requires max_workers=1") if not isinstance(self.blocked_capabilities, tuple) or not all( - isinstance(capability, Capability) for capability in self.blocked_capabilities + isinstance(capability, Capability) for capability in self.blocked_capabilities ): raise ValueError("request blocked_capabilities must be a tuple of Capability values") if len(set(self.blocked_capabilities)) != len(self.blocked_capabilities): raise ValueError("request blocked_capabilities must not contain duplicates") if not isinstance(self.approved_capabilities, tuple) or not all( - isinstance(capability, Capability) for capability in self.approved_capabilities + isinstance(capability, Capability) for capability in self.approved_capabilities ): raise ValueError("request approved_capabilities must be a tuple of Capability values") if len(set(self.approved_capabilities)) != len(self.approved_capabilities): @@ -556,7 +561,7 @@ class EventLogger(ABC): """A structured event sink scoped to one run or collector worker.""" @abstractmethod - def event(self, level: str, message: str, **fields: int | float | str) -> None: + def event(self, level: str, message: str, **fields: float | str) -> None: """Record one machine-readable event without exposing raw evidence.""" @@ -564,15 +569,15 @@ class CollectorContext: """The limited, per-worker interface exposed to a collector.""" def __init__( - self, - run_id: str, - collector_id: str, - workspace: Path, - temporary_directory: Path, - artifacts: "ArtifactWriter", - logger: EventLogger, - settings: Mapping[str, object], - cancellation_file: Path, + self, + run_id: str, + collector_id: str, + workspace: Path, + temporary_directory: Path, + artifacts: ArtifactWriter, + logger: EventLogger, + settings: Mapping[str, object], + cancellation_file: Path, ) -> None: """Initialize the limited worker context and its cancellation boundary.""" self.run_id = run_id @@ -608,7 +613,7 @@ def setting_str(self, name: str, default: str) -> str: value = self.settings.get(name, default) return value if isinstance(value, str) else default - def report_progress(self, event: str, **metrics: int | float | str) -> None: + def report_progress(self, event: str, **metrics: float | str) -> None: """Write a structured progress event local to this collector workspace.""" self.logger.event("info", event, **metrics) @@ -618,12 +623,12 @@ class ArtifactWriter(ABC): @abstractmethod def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> Artifact: """Register a file created within the collector workspace.""" diff --git a/logicytics/global/ctypes_collector.py b/logicytics/global/ctypes_collector.py index f6e240f7..c26dfb18 100644 --- a/logicytics/global/ctypes_collector.py +++ b/logicytics/global/ctypes_collector.py @@ -204,10 +204,10 @@ class MemoryStatus(ctypes.Structure): def open_process( - process_id: int, - *, - access: int = PROCESS_QUERY_LIMITED_INFORMATION, - inherit_handle: bool = False, + process_id: int, + *, + access: int = PROCESS_QUERY_LIMITED_INFORMATION, + inherit_handle: bool = False, ) -> HANDLE | None: """Open a Windows process and return its handle, or None on failure.""" @@ -235,8 +235,8 @@ def get_exit_code_process(handle: HANDLE) -> int | None: exit_code = DWORD() if not _GetExitCodeProcess( - handle, - ctypes.byref(exit_code), + handle, + ctypes.byref(exit_code), ): return None @@ -250,8 +250,8 @@ def close_handle(handle: HANDLE) -> bool: def get_process_memory_info( - process: HANDLE, - counters: ProcessMemoryCounters, + process: HANDLE, + counters: ProcessMemoryCounters, ) -> bool: """Populate memory counters for a process.""" @@ -270,8 +270,8 @@ def get_process_memory_info( def virtual_query( - address: int, - memory: MemoryBasicInformation, + address: int, + memory: MemoryBasicInformation, ) -> int: """Query the virtual-memory region containing an address.""" @@ -285,9 +285,9 @@ def virtual_query( def get_mapped_file_name( - process: HANDLE, - address: int, - buffer_size: int = 32_768, + process: HANDLE, + address: int, + buffer_size: int = 32_768, ) -> str | None: """Return the mapped filename associated with a process address.""" @@ -336,12 +336,12 @@ def get_drive_type(root: str) -> int: def get_volume_information( - root: str, - label: ctypes.Array[ctypes.c_wchar], - serial: DWORD, - maximum_component_length: DWORD, - flags: DWORD, - filesystem: ctypes.Array[ctypes.c_wchar], + root: str, + label: ctypes.Array[ctypes.c_wchar], + serial: DWORD, + maximum_component_length: DWORD, + flags: DWORD, + filesystem: ctypes.Array[ctypes.c_wchar], ) -> bool: """Populate volume metadata for a filesystem root.""" @@ -360,10 +360,10 @@ def get_volume_information( def get_disk_free_space( - root: str, - available: ULARGE_INTEGER, - total: ULARGE_INTEGER, - free: ULARGE_INTEGER, + root: str, + available: ULARGE_INTEGER, + total: ULARGE_INTEGER, + free: ULARGE_INTEGER, ) -> bool: """Populate disk space counters for a filesystem root.""" @@ -396,8 +396,8 @@ def global_memory_status() -> MemoryStatus: def query_registry_key_info( - key_handle: int, - timestamp: FILETIME, + key_handle: int, + timestamp: FILETIME, ) -> int: """Query registry key metadata and populate its last-write timestamp.""" @@ -468,35 +468,28 @@ def filetime() -> FILETIME: "ULARGE_INTEGER", "ULONG", "WORD", - # Constants "PROCESS_QUERY_LIMITED_INFORMATION", "STILL_ACTIVE", - # Process API "close_handle", "get_current_process", "get_exit_code_process", "get_process_memory_info", "open_process", - # Virtual memory API "get_mapped_file_name", "pointer_value", "virtual_query", - # Drive and volume API "get_disk_free_space", "get_drive_type", "get_logical_drives", "get_volume_information", - # Memory API "global_memory_status", - # Registry API "query_registry_key_info", - # ctypes factories "create_unicode_buffer", "dword", diff --git a/logicytics/module/api.py b/logicytics/module/api.py index 3f10836a..3c3f60eb 100644 --- a/logicytics/module/api.py +++ b/logicytics/module/api.py @@ -86,9 +86,9 @@ def load_configuration(project_root: Path | str, config_path: Path | str | None def _configuration( - project_root: Path | str, - configuration: AppConfig | None, - config_path: Path | str | None, + project_root: Path | str, + configuration: AppConfig | None, + config_path: Path | str | None, ) -> tuple[Path, AppConfig]: """Resolve one unambiguous, typed configuration source.""" root = Path(project_root).resolve() @@ -100,19 +100,17 @@ def _configuration( def plan_run( - project_root: Path | str, - request: RunRequest, - *, - configuration: AppConfig | None = None, - config_path: Path | str | None = None, + project_root: Path | str, + request: RunRequest, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, ) -> RunPlan: """Preflight and resolve a strict run without starting collectors or writing output.""" root, settings = _configuration(project_root, configuration, config_path) if not isinstance(request, RunRequest): raise PlanError("request must be an immutable RunRequest instance") - blocked_capabilities = tuple(dict.fromkeys( - (*settings.runtime.blocked_capabilities, *request.blocked_capabilities) - )) + blocked_capabilities = tuple(dict.fromkeys((*settings.runtime.blocked_capabilities, *request.blocked_capabilities))) request = replace(request, blocked_capabilities=blocked_capabilities) if request.max_workers > settings.runtime.maximum_workers: raise PlanError("requested workers exceed configured maximum_workers") @@ -120,11 +118,11 @@ def plan_run( def run_collection( - project_root: Path | str, - request: RunRequest, - *, - configuration: AppConfig | None = None, - config_path: Path | str | None = None, + project_root: Path | str, + request: RunRequest, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, ) -> RunOutcome: """Execute only a strictly validated, authorized, independently isolated run.""" root, settings = _configuration(project_root, configuration, config_path) @@ -147,14 +145,14 @@ def _artifact(raw: object, collector_id: str) -> Artifact: relative = PurePosixPath(artifact.relative_path) owner = collector_id.replace(".", "_") if ( - artifact.collector_id != collector_id - or "\\" in artifact.relative_path - or relative.is_absolute() - or ".." in relative.parts - or relative.as_posix() != artifact.relative_path - or len(relative.parts) < 2 - or relative.parts[0] != owner - or artifact.name != relative.name + artifact.collector_id != collector_id + or "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != owner + or artifact.name != relative.name ): raise PlanError("run manifest artifact violates collector ownership or path boundaries") return artifact @@ -181,10 +179,7 @@ def _collector_snapshot(record: dict[str, Any], collector_id: str, status: str) finished_at = _manifest_timestamp(record.get("finished_at"), "collector finished_at", optional=True) duration = record.get("duration_seconds") if duration is not None and ( - not isinstance(duration, (int, float)) - or isinstance(duration, bool) - or not isfinite(duration) - or duration < 0 + not isinstance(duration, (int, float)) or isinstance(duration, bool) or not isfinite(duration) or duration < 0 ): raise PlanError("run manifest collector duration_seconds must be a finite non-negative number or null") summary = record.get("summary") @@ -211,19 +206,12 @@ def _collector_snapshot(record: dict[str, Any], collector_id: str, status: str) has_valid_collector_id = raw_failure.get("collector_id") == collector_id has_valid_actionable_details = all( - isinstance(raw_failure.get(field), str) and raw_failure[field].strip() - for field in actionable_fields + isinstance(raw_failure.get(field), str) and raw_failure[field].strip() for field in actionable_fields ) has_valid_retry_flag = isinstance(raw_failure.get("retry_safe"), bool) - if not ( - has_valid_collector_id - and has_valid_actionable_details - and has_valid_retry_flag - ): - raise PlanError( - "run manifest collector failure contains invalid actionable details" - ) + if not (has_valid_collector_id and has_valid_actionable_details and has_valid_retry_flag): + raise PlanError("run manifest collector failure contains invalid actionable details") failure = CollectorFailureSnapshot( collector_id=collector_id, operation=raw_failure["operation"], @@ -246,11 +234,11 @@ def _collector_snapshot(record: dict[str, Any], collector_id: str, status: str) def query_run( - project_root: Path | str, - run_id: str, - *, - configuration: AppConfig | None = None, - config_path: Path | str | None = None, + project_root: Path | str, + run_id: str, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, ) -> RunSnapshot: """Read a run-owned manifest and return immutable, ownership-validated status.""" _, settings = _configuration(project_root, configuration, config_path) @@ -273,14 +261,11 @@ def query_run( if manifest_schema_version is None: raise PlanError("run manifest is missing schema_version") if ( - not isinstance(manifest_schema_version, int) - or isinstance(manifest_schema_version, bool) - or manifest_schema_version != MANIFEST_SCHEMA_VERSION + not isinstance(manifest_schema_version, int) + or isinstance(manifest_schema_version, bool) + or manifest_schema_version != MANIFEST_SCHEMA_VERSION ): - raise PlanError( - f"unsupported run manifest schema_version {manifest_schema_version!r}; " - f"expected {MANIFEST_SCHEMA_VERSION}" - ) + raise PlanError(f"unsupported run manifest schema_version {manifest_schema_version!r}; expected {MANIFEST_SCHEMA_VERSION}") _manifest_timestamp(payload.get("requested_at"), "requested_at") if not isinstance(payload.get("status"), str): @@ -306,12 +291,12 @@ def query_run( collector_id = record.get("id") collector_status = record.get("status") if ( - not isinstance(collector_id, str) - or _COLLECTOR_ID.fullmatch(collector_id) is None - or collector_id in collector_ids - or not isinstance(collector_status, str) - or collector_status not in valid_collector_statuses - or not isinstance(record.get("artifacts"), list) + not isinstance(collector_id, str) + or _COLLECTOR_ID.fullmatch(collector_id) is None + or collector_id in collector_ids + or not isinstance(collector_status, str) + or collector_status not in valid_collector_statuses + or not isinstance(record.get("artifacts"), list) ): raise PlanError("run manifest contains an invalid or duplicate collector record") collector_ids.add(collector_id) @@ -328,7 +313,7 @@ def query_run( if catalog != expected_catalog: raise PlanError("run manifest artifact catalog does not match collector-owned evidence") if status not in {RunStatus.PLANNED, RunStatus.RUNNING} and payload.get("total_artifact_bytes") != sum( - artifact.size_bytes for artifact in artifacts + artifact.size_bytes for artifact in artifacts ): raise PlanError("run manifest total_artifact_bytes does not match registered evidence") finished_at = _manifest_timestamp(payload.get("finished_at"), "finished_at", optional=True) @@ -346,22 +331,18 @@ def query_run( def read_artifact( - project_root: Path | str, - run_id: str, - artifact_id: str, - *, - maximum_bytes: int = _DEFAULT_ARTIFACT_READ_BYTES, - configuration: AppConfig | None = None, - config_path: Path | str | None = None, + project_root: Path | str, + run_id: str, + artifact_id: str, + *, + maximum_bytes: int = _DEFAULT_ARTIFACT_READ_BYTES, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, ) -> bytes: """Read bounded, registered evidence only after ownership and SHA-256 verification.""" if not isinstance(artifact_id, str) or _ARTIFACT_ID.fullmatch(artifact_id) is None: raise ArtifactError("artifact_id must be a canonical registered artifact identifier") - if ( - not isinstance(maximum_bytes, int) - or isinstance(maximum_bytes, bool) - or not 1 <= maximum_bytes <= _MAXIMUM_ARTIFACT_READ_BYTES - ): + if not isinstance(maximum_bytes, int) or isinstance(maximum_bytes, bool) or not 1 <= maximum_bytes <= _MAXIMUM_ARTIFACT_READ_BYTES: raise ArtifactError("maximum_bytes must be an integer from 1 to 67108864") snapshot = query_run(project_root, run_id, configuration=configuration, config_path=config_path) artifact = next((item for item in snapshot.artifacts if item.id == artifact_id), None) @@ -383,28 +364,22 @@ def read_artifact( contents = stream.read(maximum_bytes + 1) except (OSError, ValueError) as error: raise ArtifactError("registered artifact escapes its collector-owned store or cannot be read") from error - if ( - len(contents) > maximum_bytes - or len(contents) != artifact.size_bytes - or hashlib.sha256(contents).hexdigest() != artifact.sha256 - ): + if len(contents) > maximum_bytes or len(contents) != artifact.size_bytes or hashlib.sha256(contents).hexdigest() != artifact.sha256: raise ArtifactError("registered artifact failed manifest size or SHA-256 verification") return contents def open_artifact( - project_root: Path | str, - run_id: str, - artifact_id: str, - *, - configuration: AppConfig | None = None, - config_path: Path | str | None = None, + project_root: Path | str, + run_id: str, + artifact_id: str, + *, + configuration: AppConfig | None = None, + config_path: Path | str | None = None, ) -> Path: """Verify and open one registered artifact with the platform's associated application.""" if not isinstance(artifact_id, str) or _ARTIFACT_ID.fullmatch(artifact_id) is None: - raise ArtifactError( - "artifact_id must be a canonical registered artifact identifier" - ) + raise ArtifactError("artifact_id must be a canonical registered artifact identifier") snapshot = query_run( project_root, @@ -418,9 +393,7 @@ def open_artifact( None, ) if artifact is None: - raise ArtifactError( - "artifact is not registered in the selected run manifest" - ) + raise ArtifactError("artifact is not registered in the selected run manifest") root = snapshot.run_directory / "artifacts" source = root.joinpath(*PurePosixPath(artifact.relative_path).parts) @@ -434,9 +407,7 @@ def open_artifact( resolved.relative_to(owner) if not source.is_file() or source.stat().st_size != artifact.size_bytes: - raise ArtifactError( - "registered artifact size does not match its manifest" - ) + raise ArtifactError("registered artifact size does not match its manifest") digest = hashlib.sha256() with source.open("rb") as stream: @@ -444,14 +415,10 @@ def open_artifact( digest.update(block) except (OSError, ValueError) as error: - raise ArtifactError( - "registered artifact escapes its collector-owned store or cannot be opened" - ) from error + raise ArtifactError("registered artifact escapes its collector-owned store or cannot be opened") from error if digest.hexdigest() != artifact.sha256: - raise ArtifactError( - "registered artifact failed manifest SHA-256 verification" - ) + raise ArtifactError("registered artifact failed manifest SHA-256 verification") if sys.platform != "win32": raise OSError("opening artifacts requires Windows") diff --git a/logicytics/module/artifacts.py b/logicytics/module/artifacts.py index 7e1db68c..46907eec 100644 --- a/logicytics/module/artifacts.py +++ b/logicytics/module/artifacts.py @@ -7,7 +7,7 @@ import os import re import shutil -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from threading import RLock from uuid import uuid4 @@ -38,46 +38,44 @@ class WorkspaceArtifactWriter(ArtifactWriter): """Copies approved files from one collector workspace to the run artifact tree.""" def __init__( - self, - collector_id: str, - workspace: Path, - artifact_root: Path, - maximum_output_bytes: int, - maximum_artifact_files: int, - *, - source_category: str | None = None, - maximum_artifact_bytes: int | None = None, - run_output_budget_bytes: int | None = None, - cancellation_file: Path | None = None, - allowed_relative_paths: tuple[str, ...] | None = None, - allowed_media_types: tuple[str, ...] | None = None, + self, + collector_id: str, + workspace: Path, + artifact_root: Path, + maximum_output_bytes: int, + maximum_artifact_files: int, + *, + source_category: str | None = None, + maximum_artifact_bytes: int | None = None, + run_output_budget_bytes: int | None = None, + cancellation_file: Path | None = None, + allowed_relative_paths: tuple[str, ...] | None = None, + allowed_media_types: tuple[str, ...] | None = None, ) -> None: """Initialize a workspace-bound writer with collector and run output quotas.""" self._collector_id = collector_id collector_parts = collector_id.split(".", 2) - resolved_category = source_category if source_category is not None else ( - collector_parts[1] if len(collector_parts) > 1 else "" - ) + resolved_category = source_category if source_category is not None else (collector_parts[1] if len(collector_parts) > 1 else "") if not isinstance(resolved_category, str) or not resolved_category.strip(): raise ArtifactError("artifact source_category must be a non-empty string") self._source_category = resolved_category self._workspace = workspace.resolve() self._artifact_root = artifact_root.resolve() self._maximum_output_bytes = maximum_output_bytes - self._maximum_artifact_bytes = ( - maximum_output_bytes if maximum_artifact_bytes is None else maximum_artifact_bytes - ) - if not isinstance(self._maximum_artifact_bytes, int) or isinstance( + self._maximum_artifact_bytes = maximum_output_bytes if maximum_artifact_bytes is None else maximum_artifact_bytes + if ( + not isinstance(self._maximum_artifact_bytes, int) + or isinstance( self._maximum_artifact_bytes, bool, - ) or not 1 <= self._maximum_artifact_bytes <= maximum_output_bytes: + ) + or not 1 <= self._maximum_artifact_bytes <= maximum_output_bytes + ): raise ArtifactError("maximum_artifact_bytes must be a positive integer within maximum_output_bytes") self._maximum_artifact_files = maximum_artifact_files self._run_output_budget_bytes = run_output_budget_bytes if run_output_budget_bytes is not None and ( - not isinstance(run_output_budget_bytes, int) - or isinstance(run_output_budget_bytes, bool) - or run_output_budget_bytes < 1 + not isinstance(run_output_budget_bytes, int) or isinstance(run_output_budget_bytes, bool) or run_output_budget_bytes < 1 ): raise ArtifactError("run_output_budget_bytes must be a positive integer") self._cancellation_file = cancellation_file @@ -94,12 +92,12 @@ def artifacts(self) -> tuple[Artifact, ...]: return tuple(self._artifacts) def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> Artifact: """Serialize destination allocation, quota checks, publication, and catalog updates.""" with self._registration_lock: @@ -111,26 +109,24 @@ def register_file( ) def _register_file( - self, - source: Path, - *, - media_type: str, - evidence_kind: EvidenceKind, - transformations: tuple[str, ...], + self, + source: Path, + *, + media_type: str, + evidence_kind: EvidenceKind, + transformations: tuple[str, ...], ) -> Artifact: """Copy one validated source into the run artifact tree and catalog it.""" if not isinstance(media_type, str) or not re.fullmatch( - r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*", - media_type, + r"[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*/[A-Za-z0-9][A-Za-z0-9!#$&^_.+-]*", + media_type, ): raise ArtifactError("artifact media_type must be a valid MIME type") if self._allowed_media_types is not None and media_type not in self._allowed_media_types: raise ArtifactError(f"artifact media type is outside the collector output contract: {media_type}") if not isinstance(evidence_kind, EvidenceKind): raise ArtifactError("artifact evidence_kind must be an EvidenceKind value") - if not isinstance(transformations, tuple) or any( - not isinstance(step, str) or not step.strip() for step in transformations - ): + if not isinstance(transformations, tuple) or any(not isinstance(step, str) or not step.strip() for step in transformations): raise ArtifactError("artifact transformations must be a tuple of non-empty strings") self._check_cancellation() source = source.resolve() @@ -144,12 +140,10 @@ def _register_file( relative_source = source.relative_to(self._workspace) if self._allowed_relative_paths is not None and not any( - fnmatch.fnmatchcase(relative_source.as_posix(), pattern) - for pattern in self._allowed_relative_paths + fnmatch.fnmatchcase(relative_source.as_posix(), pattern) for pattern in self._allowed_relative_paths ): - raise ArtifactError( - f"artifact path is outside the collector output contract: {relative_source.as_posix()}" - ) + relative_path = relative_source.as_posix() + raise ArtifactError(f"artifact path is outside the collector output contract: {relative_path}") safe_collector_id = self._collector_id.replace(".", "_") destination = self._artifact_root / safe_collector_id / relative_source if not _is_within(destination, self._artifact_root): @@ -168,7 +162,7 @@ def _register_file( media_type=media_type, collector_id=self._collector_id, source_category=self._source_category, - collected_at=datetime.now(timezone.utc).isoformat(), + collected_at=datetime.now(UTC).isoformat(), transformations=(*transformations, "copied into run artifact store"), evidence_kind=evidence_kind, name=destination.name, @@ -188,17 +182,15 @@ def _check_output_limits(self, size_bytes: int) -> None: raise ArtifactError("collector artifact exceeds its declared maximum_artifact_bytes") if self._bytes_registered + size_bytes > self._maximum_output_bytes: raise ArtifactError("collector output exceeds its declared maximum_output_bytes") - if self._run_output_budget_bytes is not None and ( - self._bytes_registered + size_bytes > self._run_output_budget_bytes - ): + if self._run_output_budget_bytes is not None and (self._bytes_registered + size_bytes > self._run_output_budget_bytes): raise ArtifactError("run output exceeds configured maximum_run_output_bytes") def _copy_artifact( - self, - source: Path, - destination: Path, - expected_size: int, - expected_modified_at: int, + self, + source: Path, + destination: Path, + expected_size: int, + expected_modified_at: int, ) -> str: """Stream bounded evidence to an atomic destination while observing cancellation.""" temporary = destination.with_name(f".{destination.name}.{uuid4().hex}.tmp") @@ -215,11 +207,7 @@ def _copy_artifact( destination_stream.write(block) digest.update(block) final_stat = source.stat() - if ( - copied_bytes != expected_size - or final_stat.st_size != expected_size - or final_stat.st_mtime_ns != expected_modified_at - ): + if copied_bytes != expected_size or final_stat.st_size != expected_size or final_stat.st_mtime_ns != expected_modified_at: raise ArtifactError("artifact source changed during registration") shutil.copystat(source, temporary) self._check_cancellation() diff --git a/logicytics/module/command_runner.py b/logicytics/module/command_runner.py index c88bfbac..ebb46c00 100644 --- a/logicytics/module/command_runner.py +++ b/logicytics/module/command_runner.py @@ -2,8 +2,8 @@ from __future__ import annotations +from collections.abc import Iterable from dataclasses import dataclass -from typing import Iterable from logicytics.platform_adapters import process_adapter @@ -25,9 +25,7 @@ def run_command(command: Iterable[str], *, timeout_seconds: float = 30) -> Comma raise ValueError("command must contain at least one argument") if timeout_seconds <= 0: raise ValueError("timeout_seconds must be positive") - completed = process_adapter.run( - normalized, capture_output=True, check=False, text=True, timeout=timeout_seconds - ) + completed = process_adapter.run(normalized, capture_output=True, check=False, text=True, timeout=timeout_seconds) return CommandResult(normalized, completed.returncode, completed.stdout, completed.stderr) @@ -37,8 +35,15 @@ def parse_level_messages(output: str) -> tuple[tuple[str, str], ...]: for line in output.splitlines(): level, separator, message = line.partition(":") normalized_level = level.strip().upper() - if not separator or normalized_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", - "EXCEPTION"}: + if not separator or normalized_level not in { + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL", + "INTERNAL", + "EXCEPTION", + }: continue messages.append((normalized_level, message.strip())) return tuple(messages) diff --git a/logicytics/module/configuration.py b/logicytics/module/configuration.py index d1df2977..b13849a9 100644 --- a/logicytics/module/configuration.py +++ b/logicytics/module/configuration.py @@ -5,11 +5,12 @@ import hashlib import json import re +from collections.abc import Mapping from dataclasses import asdict, dataclass, field from math import isfinite from pathlib import Path from types import MappingProxyType -from typing import Any, Mapping +from typing import Any from logicytics.contracts import Capability from logicytics.module.errors import PlanError @@ -19,32 +20,57 @@ MAXIMUM_CONFIGURATION_BYTES = 2 * 1024 * 1024 DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 -_COLLECTOR_ID = re.compile( - r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|(?:plugin|mod)\.[a-z][a-z0-9_]*)$" -) +_COLLECTOR_ID = re.compile(r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|(?:plugin|mod)\.[a-z][a-z0-9_]*)$") _SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") -_ROOT_FIELDS = frozenset({ - "schema_version", "runtime", "interaction", "maintenance", "logging", "collectors", -}) -_RUNTIME_FIELDS = frozenset({ - "output_root", "default_max_workers", "maximum_workers", "package_completed_runs", "maximum_run_output_bytes", - "blocked_capabilities", -}) +_ROOT_FIELDS = frozenset( + { + "schema_version", + "runtime", + "interaction", + "maintenance", + "logging", + "collectors", + } +) +_RUNTIME_FIELDS = frozenset( + { + "output_root", + "default_max_workers", + "maximum_workers", + "package_completed_runs", + "maximum_run_output_bytes", + "blocked_capabilities", + } +) _INTERACTION_FIELDS = frozenset({"history_enabled", "similarity_threshold", "model_name", "model_debug"}) -_MAINTENANCE_FIELDS = frozenset({ - "remote_manifest_url", "remote_manifest_sha256", "local_manifest_path", - "minimum_python", "recommended_python", "sysinternals_enabled", "sysinternals_download_url", -}) -_LOGGING_FIELDS = frozenset({ - "level", "console_enabled", "color_enabled", "file_enabled", "maximum_bytes", - "delete_previous", "retention_days", -}) +_MAINTENANCE_FIELDS = frozenset( + { + "remote_manifest_url", + "remote_manifest_sha256", + "local_manifest_path", + "minimum_python", + "recommended_python", + "sysinternals_enabled", + "sysinternals_download_url", + } +) +_LOGGING_FIELDS = frozenset( + { + "level", + "console_enabled", + "color_enabled", + "file_enabled", + "maximum_bytes", + "delete_previous", + "retention_days", + } +) _LOG_LEVELS = frozenset({"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION"}) DEFAULT_CONFIGURATION_FILENAME = "logicytics.yaml" DEFAULT_SYSINTERNALS_DOWNLOAD_URL = "https://download.sysinternals.com/files/SysinternalsSuite.zip" -type CollectorSettingValue = str | int | float -type CollectorSettings = Mapping[str, CollectorSettingValue] +CollectorSettingValue = str | int | float +CollectorSettings = Mapping[str, CollectorSettingValue] @dataclass(frozen=True, slots=True) @@ -59,45 +85,47 @@ class CollectorSettingRule: _COLLECTOR_SETTING_SCHEMAS: Mapping[ str, Mapping[str, CollectorSettingRule], -] = MappingProxyType({ - "core.network.bandwidth_sample": { - "sample_count": CollectorSettingRule("integer", 1, 10), - "interval_seconds": CollectorSettingRule("number", 0.1, 60), - }, - "core.packet.packet_capture": { - "packet_count": CollectorSettingRule("integer", 1, 10_000), - "timeout_seconds": CollectorSettingRule("number", 1, 60), - "retry_window_seconds": CollectorSettingRule("number", 0, 60), - "interface": CollectorSettingRule("text"), - }, - "core.filesystem.system_drive_tree": { - "max_entries": CollectorSettingRule("integer", 1, 50_000), - "max_depth": CollectorSettingRule("integer", 1, 32), - }, - "core.filesystem.system_drive_listing": { - "max_entries": CollectorSettingRule("integer", 1, 50_000), - "max_depth": CollectorSettingRule("integer", 1, 32), - }, - "core.filesystem.sensitive_file_inventory": { - "root": CollectorSettingRule("absolute_path"), - "max_directories": CollectorSettingRule("integer", 1, 50_000), - "max_matches": CollectorSettingRule("integer", 1, 5_000), - }, - "core.process.memory_map": { - "max_regions": CollectorSettingRule("integer", 1, 100_000), - "output_limit_bytes": CollectorSettingRule( - "integer", - 1_024, - 64 * 1024 * 1024, - ), - "disk_safety_margin_bytes": CollectorSettingRule( - "integer", - 0, - MAXIMUM_RUN_OUTPUT_BYTES, - ), - "dump_directory": CollectorSettingRule("workspace_path"), - }, -}) +] = MappingProxyType( + { + "core.network.bandwidth_sample": { + "sample_count": CollectorSettingRule("integer", 1, 10), + "interval_seconds": CollectorSettingRule("number", 0.1, 60), + }, + "core.packet.packet_capture": { + "packet_count": CollectorSettingRule("integer", 1, 10_000), + "timeout_seconds": CollectorSettingRule("number", 1, 60), + "retry_window_seconds": CollectorSettingRule("number", 0, 60), + "interface": CollectorSettingRule("text"), + }, + "core.filesystem.system_drive_tree": { + "max_entries": CollectorSettingRule("integer", 1, 50_000), + "max_depth": CollectorSettingRule("integer", 1, 32), + }, + "core.filesystem.system_drive_listing": { + "max_entries": CollectorSettingRule("integer", 1, 50_000), + "max_depth": CollectorSettingRule("integer", 1, 32), + }, + "core.filesystem.sensitive_file_inventory": { + "root": CollectorSettingRule("absolute_path"), + "max_directories": CollectorSettingRule("integer", 1, 50_000), + "max_matches": CollectorSettingRule("integer", 1, 5_000), + }, + "core.process.memory_map": { + "max_regions": CollectorSettingRule("integer", 1, 100_000), + "output_limit_bytes": CollectorSettingRule( + "integer", + 1_024, + 64 * 1024 * 1024, + ), + "disk_safety_margin_bytes": CollectorSettingRule( + "integer", + 0, + MAXIMUM_RUN_OUTPUT_BYTES, + ), + "dump_directory": CollectorSettingRule("workspace_path"), + }, + } +) def _positive_integer(value: object, *, minimum: int, maximum: int) -> bool: @@ -142,9 +170,7 @@ def _validate_collector_settings(settings: Mapping[str, Mapping[str, Any]]) -> N schema = _COLLECTOR_SETTING_SCHEMAS.get(collector_id) if schema is None: if collector_id.startswith("core.") and values: - raise PlanError( - f"{collector_id} does not declare configurable settings" - ) + raise PlanError(f"{collector_id} does not declare configurable settings") continue unknown = sorted(set(values) - set(schema)) if unknown: @@ -203,10 +229,10 @@ def _yaml_scalar(value: str, *, line_number: int) -> object: return False if value == "{}": return {} - if value.startswith(("\"", "'")): + if value.startswith(('"', "'")): if not value.endswith(value[0]): raise PlanError(f"invalid YAML string at line {line_number}") - if value[0] == "\"": + if value[0] == '"': try: return json.loads(value) except json.JSONDecodeError as error: @@ -258,7 +284,7 @@ def _load_yaml_mapping(payload: bytes) -> dict[str, Any]: raise PlanError(f"invalid YAML mapping entry at line {number}") key, value = content.split(":", 1) key = key.strip() - if not key or key.startswith(("\"", "'")) or any(character.isspace() for character in key): + if not key or key.startswith(('"', "'")) or any(character.isspace() for character in key): raise PlanError(f"invalid YAML key at line {number}") records.append((indent, number, key, value.strip())) if not records: @@ -290,7 +316,39 @@ def parse_mapping(index: int, indentation: int) -> tuple[dict[str, Any], int]: def default_configuration_yaml() -> str: """Return the YAML template written by installer and repair flows.""" - return """# Logicytics user configuration\nschema_version: 4\nruntime:\n output_root: output/data\n default_max_workers: 4\n maximum_workers: 16\n package_completed_runs: true\n blocked_capabilities: {}\ninteraction:\n history_enabled: false\n similarity_threshold: 0.55\n model_name: stdlib-sequence-matcher\n model_debug: false\nmaintenance:\n local_manifest_path: project.manifest.json\n minimum_python: \"3.11\"\n recommended_python: \"3.11\"\n sysinternals_enabled: true\n sysinternals_download_url: https://download.sysinternals.com/files/SysinternalsSuite.zip\nlogging:\n level: INFO\n console_enabled: true\n color_enabled: true\n file_enabled: true\n maximum_bytes: 4194304\n delete_previous: false\n retention_days: 30\ncollectors: {}\n""" + return "\n".join( + ( + "# Logicytics user configuration", + "schema_version: 4", + "runtime:", + " output_root: output/data", + " default_max_workers: 4", + " maximum_workers: 16", + " package_completed_runs: true", + " blocked_capabilities: {}", + "interaction:", + " history_enabled: false", + " similarity_threshold: 0.55", + " model_name: stdlib-sequence-matcher", + " model_debug: false", + "maintenance:", + " local_manifest_path: project.manifest.json", + ' minimum_python: "3.11"', + ' recommended_python: "3.11"', + " sysinternals_enabled: true", + " sysinternals_download_url: https://download.sysinternals.com/files/SysinternalsSuite.zip", + "logging:", + " level: INFO", + " console_enabled: true", + " color_enabled: true", + " file_enabled: true", + " maximum_bytes: 4194304", + " delete_previous: false", + " retention_days: 30", + "collectors: {}", + "", + ) + ) def write_default_configuration(project_root: Path, *, overwrite: bool = False) -> Path: @@ -386,8 +444,10 @@ def fingerprint(self) -> str: def default_config(project_root: Path) -> AppConfig: """Create safe defaults rooted at the checked-out project.""" - return AppConfig(schema_version=SCHEMA_VERSION, - runtime=RuntimeSettings(output_root=project_root / "output" / "data")) + return AppConfig( + schema_version=SCHEMA_VERSION, + runtime=RuntimeSettings(output_root=project_root / "output" / "data"), + ) def load_config(project_root: Path, config_path: Path | None = None) -> AppConfig: @@ -412,9 +472,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi if not isinstance(schema_version, int) or isinstance(schema_version, bool): raise PlanError("configuration schema_version must be an integer") if schema_version != SCHEMA_VERSION: - raise PlanError( - f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION}" - ) + raise PlanError(f"unsupported configuration schema_version {schema_version}; expected {SCHEMA_VERSION}") unknown_root = sorted(set(raw) - _ROOT_FIELDS) if unknown_root: raise PlanError(f"configuration contains unsupported root settings: {', '.join(unknown_root)}") @@ -434,9 +492,9 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi default_workers = runtime_raw.get("default_max_workers", 4) maximum_workers = runtime_raw.get("maximum_workers", 16) if not _positive_integer(default_workers, minimum=1, maximum=64) or not _positive_integer( - maximum_workers, - minimum=1, - maximum=64, + maximum_workers, + minimum=1, + maximum=64, ): raise PlanError("worker limits must be integers") if not 1 <= default_workers <= maximum_workers <= 64: @@ -449,9 +507,9 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES, ) if not _positive_integer( - maximum_run_output_bytes, - minimum=1, - maximum=MAXIMUM_RUN_OUTPUT_BYTES, + maximum_run_output_bytes, + minimum=1, + maximum=MAXIMUM_RUN_OUTPUT_BYTES, ): raise PlanError("runtime maximum_run_output_bytes must be an integer from 1 to 68719476736") blocked_capabilities = _configured_capabilities( @@ -464,7 +522,8 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError("interaction configuration must be an object") unknown_interaction = sorted(set(interaction_raw) - _INTERACTION_FIELDS) if unknown_interaction: - raise PlanError(f"interaction configuration contains unsupported settings: {', '.join(unknown_interaction)}") + unsupported_settings = ", ".join(unknown_interaction) + raise PlanError(f"interaction configuration contains unsupported settings: {unsupported_settings}") history_enabled = interaction_raw.get("history_enabled", False) model_debug = interaction_raw.get("model_debug", False) similarity_threshold = interaction_raw.get("similarity_threshold", 0.55) @@ -481,51 +540,29 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError("maintenance configuration must be an object") unknown_maintenance = sorted(set(maintenance_raw) - _MAINTENANCE_FIELDS) if unknown_maintenance: - raise PlanError( - "maintenance configuration contains unsupported settings: " - f"{', '.join(unknown_maintenance)}" - ) + raise PlanError(f"maintenance configuration contains unsupported settings: {', '.join(unknown_maintenance)}") remote_url = maintenance_raw.get("remote_manifest_url") remote_sha256 = maintenance_raw.get("remote_manifest_sha256") if (remote_url is None) != (remote_sha256 is None): raise PlanError("remote manifest URL and SHA-256 must be configured together") - if remote_url is not None and ( - not isinstance(remote_url, str) - or not remote_url.startswith("https://") - or "\n" in remote_url - ): + if remote_url is not None and (not isinstance(remote_url, str) or not remote_url.startswith("https://") or "\n" in remote_url): raise PlanError("remote_manifest_url must be an HTTPS URL") - if remote_sha256 is not None and ( - not isinstance(remote_sha256, str) - or re.fullmatch(r"[0-9a-f]{64}", remote_sha256) is None - ): + if remote_sha256 is not None and (not isinstance(remote_sha256, str) or re.fullmatch(r"[0-9a-f]{64}", remote_sha256) is None): raise PlanError("remote_manifest_sha256 must be a lowercase SHA-256 digest") local_manifest_value = maintenance_raw.get("local_manifest_path", "project.manifest.json") if not isinstance(local_manifest_value, str) or not local_manifest_value.strip(): raise PlanError("local_manifest_path must be a non-empty relative path") local_manifest_path = Path(local_manifest_value) - if ( - local_manifest_path.is_absolute() - or local_manifest_path.drive - or ".." in local_manifest_path.parts - ): + if local_manifest_path.is_absolute() or local_manifest_path.drive or ".." in local_manifest_path.parts: raise PlanError("local_manifest_path must remain inside the project") version_pattern = re.compile(r"^\d+\.\d+$") minimum_python = maintenance_raw.get("minimum_python", "3.11") recommended_python = maintenance_raw.get("recommended_python", "3.11") - if ( - not isinstance(minimum_python, str) - or version_pattern.fullmatch(minimum_python) is None - ): + if not isinstance(minimum_python, str) or version_pattern.fullmatch(minimum_python) is None: raise PlanError("minimum_python must use major.minor form") - if ( - not isinstance(recommended_python, str) - or version_pattern.fullmatch(recommended_python) is None - ): + if not isinstance(recommended_python, str) or version_pattern.fullmatch(recommended_python) is None: raise PlanError("recommended_python must use major.minor form") - if tuple(map(int, recommended_python.split("."))) < tuple( - map(int, minimum_python.split(".")) - ): + if tuple(map(int, recommended_python.split("."))) < tuple(map(int, minimum_python.split("."))): raise PlanError("recommended_python must not be older than minimum_python") sysinternals_enabled = maintenance_raw.get("sysinternals_enabled", True) sysinternals_download_url = maintenance_raw.get( @@ -542,9 +579,7 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi raise PlanError("logging configuration must be an object") unknown_logging = sorted(set(logging_raw) - _LOGGING_FIELDS) if unknown_logging: - raise PlanError( - f"logging configuration contains unsupported settings: {', '.join(unknown_logging)}" - ) + raise PlanError(f"logging configuration contains unsupported settings: {', '.join(unknown_logging)}") logging_level = logging_raw.get("level", "INFO") if not isinstance(logging_level, str) or logging_level.upper() not in _LOG_LEVELS: raise PlanError("logging level must be DEBUG, INFO, WARNING, ERROR, CRITICAL, INTERNAL, or EXCEPTION") @@ -552,30 +587,18 @@ def load_config(project_root: Path, config_path: Path | None = None) -> AppConfi color_enabled = logging_raw.get("color_enabled", True) file_enabled = logging_raw.get("file_enabled", True) delete_previous = logging_raw.get("delete_previous", False) - if not all( - isinstance(value, bool) - for value in (console_enabled, color_enabled, file_enabled, delete_previous) - ): + if not all(isinstance(value, bool) for value in (console_enabled, color_enabled, file_enabled, delete_previous)): raise PlanError("logging enable, color, file, and deletion settings must be boolean") log_maximum_bytes = logging_raw.get("maximum_bytes", 4 * 1024 * 1024) - if ( - not isinstance(log_maximum_bytes, int) - or isinstance(log_maximum_bytes, bool) - or not 1024 <= log_maximum_bytes <= 64 * 1024 * 1024 - ): + if not isinstance(log_maximum_bytes, int) or isinstance(log_maximum_bytes, bool) or not 1024 <= log_maximum_bytes <= 64 * 1024 * 1024: raise PlanError("logging maximum_bytes must be an integer from 1024 to 67108864") retention_days = logging_raw.get("retention_days", 30) - if ( - not isinstance(retention_days, int) - or isinstance(retention_days, bool) - or not 0 <= retention_days <= 3650 - ): + if not isinstance(retention_days, int) or isinstance(retention_days, bool) or not 0 <= retention_days <= 3650: raise PlanError("logging retention_days must be an integer from 0 to 3650") collector_settings = raw.get("collectors", {}) if not isinstance(collector_settings, dict) or not all( - isinstance(key, str) and isinstance(value, dict) - for key, value in collector_settings.items() + isinstance(key, str) and isinstance(value, dict) for key, value in collector_settings.items() ): raise PlanError("collectors configuration must map collector IDs to objects") _validate_collector_settings(collector_settings) diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index 43883ec1..ca287c01 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -13,7 +13,13 @@ from pathlib import Path from subprocess import TimeoutExpired -from logicytics.contracts import CONTRACT_VERSION, Capability, CollectorKind, CollectorMetadata, Specialty +from logicytics.contracts import ( + CONTRACT_VERSION, + Capability, + CollectorKind, + CollectorMetadata, + Specialty, +) from logicytics.platform_adapters import process_adapter _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") @@ -22,9 +28,24 @@ _REMOVED_MOD_EXTENSIONS = frozenset({".ps1", ".bat", ".exe"}) _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} _APPLICATION_IMPORTS = { - "CollectorSnapshot", "RunSnapshot", "api", "artifacts", "cli", "configuration", "discovery", "environment", - "load_configuration", "manifest", "packaging", "plan_run", "planner", "query_run", "read_artifact", "runtime", - "run_collection", "validation_worker", + "CollectorSnapshot", + "RunSnapshot", + "api", + "artifacts", + "cli", + "configuration", + "discovery", + "environment", + "load_configuration", + "manifest", + "packaging", + "plan_run", + "planner", + "query_run", + "read_artifact", + "runtime", + "run_collection", + "validation_worker", } _CACHE_SCHEMA_VERSION = 2 _COLLECTOR_SERVICE_MODULES = { @@ -103,11 +124,11 @@ def invalid(self) -> tuple[CollectorCandidate, ...]: return tuple(candidate for candidate in self.candidates if not candidate.valid) def to_dict( - self, - *, - selected_plugins: tuple[str, ...] = (), - enable_plugins: bool = False, - enable_mods: bool = False, + self, + *, + selected_plugins: tuple[str, ...] = (), + enable_plugins: bool = False, + enable_mods: bool = False, ) -> dict[str, list[dict[str, object]]]: """Classify invalid plugins as quarantined unless the request selects them.""" valid = [ @@ -126,10 +147,10 @@ def to_dict( "diagnostics": [asdict(diagnostic) for diagnostic in candidate.diagnostics], } if ( - candidate.kind is CollectorKind.CORE - or (enable_plugins and candidate.kind is CollectorKind.PLUGIN) - or (enable_mods and candidate.kind is CollectorKind.MOD) - or candidate.selection_id in selected + candidate.kind is CollectorKind.CORE + or (enable_plugins and candidate.kind is CollectorKind.PLUGIN) + or (enable_mods and candidate.kind is CollectorKind.MOD) + or candidate.selection_id in selected ): invalid.append(item) else: @@ -299,9 +320,7 @@ def _validate_import_time_expressions(tree: ast.Module, candidate: CollectorCand finder.visit(tree) for call in finder.calls: name = _top_level_call_name(call) or ast.unparse(call.func) - candidate.static_errors.append( - f"forbidden import-time call: {name} (line {call.lineno})" - ) + candidate.static_errors.append(f"forbidden import-time call: {name} (line {call.lineno})") def _validate_engine_boundary_imports(tree: ast.Module, candidate: CollectorCandidate) -> None: @@ -311,35 +330,25 @@ def _validate_engine_boundary_imports(tree: ast.Module, candidate: CollectorCand if isinstance(node, ast.Import): for alias in node.names: if alias.name.startswith("logicytics.") and alias.name not in _COLLECTOR_SERVICE_MODULES: - candidate.static_errors.append( - f"forbidden application import: {alias.name} (line {node.lineno})" - ) + candidate.static_errors.append(f"forbidden application import: {alias.name} (line {node.lineno})") elif alias.name == "logicytics": application_aliases.add(alias.asname or "logicytics") elif isinstance(node, ast.ImportFrom): module = node.module or "" if module.startswith("logicytics.") and module not in _COLLECTOR_SERVICE_MODULES: - candidate.static_errors.append( - f"forbidden application import: {module} (line {node.lineno})" - ) + candidate.static_errors.append(f"forbidden application import: {module} (line {node.lineno})") elif module == "logicytics": - forbidden = sorted( - alias.name for alias in node.names if alias.name in _APPLICATION_IMPORTS - ) + forbidden = sorted(alias.name for alias in node.names if alias.name in _APPLICATION_IMPORTS) if forbidden: - candidate.static_errors.append( - f"forbidden application import: {', '.join(forbidden)} (line {node.lineno})" - ) + candidate.static_errors.append(f"forbidden application import: {', '.join(forbidden)} (line {node.lineno})") for node in ast.walk(tree): if ( - isinstance(node, ast.Attribute) - and isinstance(node.value, ast.Name) - and node.value.id in application_aliases - and node.attr in _APPLICATION_IMPORTS + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in application_aliases + and node.attr in _APPLICATION_IMPORTS ): - candidate.static_errors.append( - f"forbidden application import: {node.value.id}.{node.attr} (line {node.lineno})" - ) + candidate.static_errors.append(f"forbidden application import: {node.value.id}.{node.attr} (line {node.lineno})") def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: @@ -366,11 +375,9 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: elif public_classes[0].name != expected_class: candidate.static_errors.append(f"collector class must be named {expected_class}") elif not any( - (isinstance(base, ast.Name) and base.id == ( - "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) - or (isinstance(base, ast.Attribute) and base.attr == ( - "CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) - for base in public_classes[0].bases + (isinstance(base, ast.Name) and base.id == ("CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + or (isinstance(base, ast.Attribute) and base.attr == ("CoreCollector" if kind is CollectorKind.CORE else "PluginCollector")) + for base in public_classes[0].bases ): candidate.static_errors.append("collector class must inherit from its required base class") elif public_classes: @@ -418,10 +425,24 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: candidate.static_errors.append("mod metadata sidecar must contain an object") return candidate required = { - "id", "name", "version", "specialty", "description", "author", "supported_platforms", - "capabilities", "privilege_level", "sensitive_data_categories", "network_access", - "estimated_cost", "timeout_seconds", "maximum_output_bytes", "maximum_artifact_files", - "output_media_types", "minimum_contract_version", "default_profiles", + "id", + "name", + "version", + "specialty", + "description", + "author", + "supported_platforms", + "capabilities", + "privilege_level", + "sensitive_data_categories", + "network_access", + "estimated_cost", + "timeout_seconds", + "maximum_output_bytes", + "maximum_artifact_files", + "output_media_types", + "minimum_contract_version", + "default_profiles", } missing = sorted(required - set(raw)) if missing: @@ -443,8 +464,8 @@ def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: def _validate_class_shape( - class_node: ast.ClassDef, - candidate: CollectorCandidate, + class_node: ast.ClassDef, + candidate: CollectorCandidate, ) -> None: """Enforce the small, documented public API allowed on a collector class.""" if ast.get_docstring(class_node) is None: @@ -476,21 +497,15 @@ def _validate_class_shape( if not isinstance(node, ast.Call): continue - is_metadata_constructor = ( - isinstance(node.func, ast.Name) - and node.func.id == "CollectorMetadata" - ) or ( - isinstance(node.func, ast.Attribute) - and node.func.attr == "CollectorMetadata" - ) + is_metadata_constructor = (isinstance(node.func, ast.Name) and node.func.id == "CollectorMetadata") or ( + isinstance(node.func, ast.Attribute) and node.func.attr == "CollectorMetadata" + ) if is_metadata_constructor: metadata_calls.append(node) if metadata_method is not None and len(metadata_calls) != 1: - candidate.static_errors.append( - "metadata must construct one CollectorMetadata object directly" - ) + candidate.static_errors.append("metadata must construct one CollectorMetadata object directly") if candidate.kind is CollectorKind.PLUGIN and metadata_method is not None: required_plugin_fields = { @@ -508,25 +523,16 @@ def _validate_class_shape( if len(metadata_calls) == 1: metadata_call = metadata_calls[0] - declared_fields = { - keyword.arg - for keyword in metadata_call.keywords - if keyword.arg is not None - } + declared_fields = {keyword.arg for keyword in metadata_call.keywords if keyword.arg is not None} missing_fields = sorted(required_plugin_fields - declared_fields) if missing_fields: - candidate.static_errors.append( - "plugin metadata must explicitly declare: " - f"{', '.join(missing_fields)}" - ) + candidate.static_errors.append(f"plugin metadata must explicitly declare: {', '.join(missing_fields)}") if candidate.kind is CollectorKind.CORE and len(metadata_calls) == 1: if not any(keyword.arg == "capabilities" for keyword in metadata_calls[0].keywords): - candidate.static_errors.append( - "CAPABILITY_METADATA_MISSING: core metadata must explicitly declare capabilities" - ) + candidate.static_errors.append("CAPABILITY_METADATA_MISSING: core metadata must explicitly declare capabilities") collect_method = methods.get("collect") @@ -534,32 +540,22 @@ def _validate_class_shape( metadata_call = metadata_calls[0] declared_keyword = next( - ( - keyword - for keyword in metadata_call.keywords - if keyword.arg == "output_media_types" - ), + (keyword for keyword in metadata_call.keywords if keyword.arg == "output_media_types"), None, ) if declared_keyword is None: - candidate.static_errors.append( - "metadata must explicitly declare output_media_types" - ) + candidate.static_errors.append("metadata must explicitly declare output_media_types") else: try: evaluated_media_types = ast.literal_eval(declared_keyword.value) declared_media_types = set(evaluated_media_types) except (TypeError, ValueError): - candidate.static_errors.append( - "metadata output_media_types must be a literal tuple" - ) + candidate.static_errors.append("metadata output_media_types must be a literal tuple") else: registered_media_types: set[str] = set() - lifecycle_methods: list[ - ast.FunctionDef | ast.AsyncFunctionDef - ] = [collect_method] + lifecycle_methods: list[ast.FunctionDef | ast.AsyncFunctionDef] = [collect_method] finalize_method = methods.get("finalize") if finalize_method is not None: @@ -570,53 +566,28 @@ def _validate_class_shape( if not isinstance(call, ast.Call): continue - if not ( - isinstance(call.func, ast.Attribute) - and call.func.attr == "register_file" - ): + if not (isinstance(call.func, ast.Attribute) and call.func.attr == "register_file"): continue media_keyword = next( - ( - keyword - for keyword in call.keywords - if keyword.arg == "media_type" - ), + (keyword for keyword in call.keywords if keyword.arg == "media_type"), None, ) if media_keyword is None: - registered_media_types.add( - "application/octet-stream" - ) - elif ( - isinstance(media_keyword.value, ast.Constant) - and isinstance(media_keyword.value.value, str) - ): - registered_media_types.add( - media_keyword.value.value - ) + registered_media_types.add("application/octet-stream") + elif isinstance(media_keyword.value, ast.Constant) and isinstance(media_keyword.value.value, str): + registered_media_types.add(media_keyword.value.value) else: - candidate.static_errors.append( - "register_file media_type must be a literal " - f"string (line {call.lineno})" - ) - - if not registered_media_types.issubset( - declared_media_types - ): - candidate.static_errors.append( - "metadata output_media_types must include every " - "registered artifact type" - ) + candidate.static_errors.append(f"register_file media_type must be a literal string (line {call.lineno})") + + if not registered_media_types.issubset(declared_media_types): + candidate.static_errors.append("metadata output_media_types must include every registered artifact type") unknown = sorted(set(methods) - allowed) if unknown: - candidate.static_errors.append( - "unsupported public collector methods: " - f"{', '.join(unknown)}" - ) + candidate.static_errors.append(f"unsupported public collector methods: {', '.join(unknown)}") required = { "metadata", @@ -639,67 +610,39 @@ def _validate_class_shape( missing = sorted(required - set(methods)) if missing: - candidate.static_errors.append( - f"missing required collector methods: {', '.join(missing)}" - ) + candidate.static_errors.append(f"missing required collector methods: {', '.join(missing)}") for name, method in methods.items(): if ast.get_docstring(method) is None: - candidate.static_errors.append( - f"{name} requires a docstring" - ) + candidate.static_errors.append(f"{name} requires a docstring") if method.returns is None: - candidate.static_errors.append( - f"{name} requires a return type annotation" - ) + candidate.static_errors.append(f"{name} requires a return type annotation") elif name in expected_returns: actual_return = ast.unparse(method.returns).replace(" ", "") expected_return = expected_returns[name] if actual_return != expected_return: - candidate.static_errors.append( - f"{name} must return {expected_return}" - ) + candidate.static_errors.append(f"{name} must return {expected_return}") parameters = method.args.args - expected_parameter_count = ( - 1 - if name in {"metadata", "dependencies"} - else 3 - if name == "finalize" - else 2 - ) + expected_parameter_count = 1 if name in {"metadata", "dependencies"} else 3 if name == "finalize" else 2 if len(parameters) != expected_parameter_count: - candidate.static_errors.append( - f"{name} has an invalid parameter count" - ) + candidate.static_errors.append(f"{name} has an invalid parameter count") continue - expected_first = ( - "cls" - if name in {"metadata", "dependencies"} - else "self" - ) + expected_first = "cls" if name in {"metadata", "dependencies"} else "self" if parameters[0].arg != expected_first: - candidate.static_errors.append( - f"{name} must begin with {expected_first}" - ) + candidate.static_errors.append(f"{name} must begin with {expected_first}") if name in {"metadata", "dependencies"}: - is_classmethod = any( - isinstance(decorator, ast.Name) - and decorator.id == "classmethod" - for decorator in method.decorator_list - ) + is_classmethod = any(isinstance(decorator, ast.Name) and decorator.id == "classmethod" for decorator in method.decorator_list) if not is_classmethod: - candidate.static_errors.append( - f"{name} must be a classmethod" - ) + candidate.static_errors.append(f"{name} must be a classmethod") continue @@ -708,17 +651,10 @@ def _validate_class_shape( if context_parameter.annotation is None: context_annotation = None else: - context_annotation = ast.unparse( - context_parameter.annotation - ).rsplit(".", 1)[-1] + context_annotation = ast.unparse(context_parameter.annotation).rsplit(".", 1)[-1] - if ( - context_parameter.arg != "context" - or context_annotation != "CollectorContext" - ): - candidate.static_errors.append( - f"{name} must accept an annotated context parameter" - ) + if context_parameter.arg != "context" or context_annotation != "CollectorContext": + candidate.static_errors.append(f"{name} must accept an annotated context parameter") if name == "finalize": result_parameter = parameters[2] @@ -726,29 +662,16 @@ def _validate_class_shape( if result_parameter.annotation is None: result_annotation = None else: - result_annotation = ast.unparse( - result_parameter.annotation - ).rsplit(".", 1)[-1] + result_annotation = ast.unparse(result_parameter.annotation).rsplit(".", 1)[-1] - if ( - result_parameter.arg != "result" - or result_annotation != "CollectorResult" - ): - candidate.static_errors.append( - "finalize must accept an annotated result parameter" - ) + if result_parameter.arg != "result" or result_annotation != "CollectorResult": + candidate.static_errors.append("finalize must accept an annotated result parameter") def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: """Discover candidates without importing their modules.""" - candidates = [ - _validate_static(path, CollectorKind.CORE) - for path in _iter_candidates(project_root, CollectorKind.CORE) - ] - candidates.extend( - _validate_static(path, CollectorKind.PLUGIN) - for path in _iter_candidates(project_root, CollectorKind.PLUGIN) - ) + candidates = [_validate_static(path, CollectorKind.CORE) for path in _iter_candidates(project_root, CollectorKind.CORE)] + candidates.extend(_validate_static(path, CollectorKind.PLUGIN) for path in _iter_candidates(project_root, CollectorKind.PLUGIN)) mods_root = project_root / "MODS" if mods_root.exists(): for path in sorted(item for item in mods_root.rglob("*") if item.is_file()): @@ -773,11 +696,7 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> candidate.runtime_error = f"invalid validation response: {error}" return - specialty = ( - metadata.specialty.value - if isinstance(metadata.specialty, Specialty) - else metadata.specialty - ) + specialty = metadata.specialty.value if isinstance(metadata.specialty, Specialty) else metadata.specialty if metadata.minimum_contract_version != CONTRACT_VERSION: candidate.runtime_error = "collector contract version is unsupported" @@ -787,12 +706,10 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> candidate.runtime_error = "collector ID must start with its owner kind" elif candidate.kind is CollectorKind.CORE and specialty != candidate.path.parent.name: candidate.runtime_error = "core collector specialty must match its parent folder" - elif candidate.kind is CollectorKind.CORE and metadata.id != ( - f"core.{candidate.path.parent.name}.{candidate.path.stem}" - ): + elif candidate.kind is CollectorKind.CORE and metadata.id != (f"core.{candidate.path.parent.name}.{candidate.path.stem}"): candidate.runtime_error = "core collector ID must match core//.py" elif candidate.kind is CollectorKind.PLUGIN and metadata.id != ( - f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" + f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" ): candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" elif candidate.kind is CollectorKind.MOD and metadata.id != f"mod.{candidate.path.stem}": @@ -919,9 +836,9 @@ def preflight(project_root: Path, *, configuration_hash: str = "unconfigured") - relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() entry = cached.get(relative_path) if ( - isinstance(entry, dict) - and entry.get("kind") == candidate.kind.value - and entry.get("source_hash") == _source_hash(candidate.path) + isinstance(entry, dict) + and entry.get("kind") == candidate.kind.value + and entry.get("source_hash") == _source_hash(candidate.path) ): _accept_runtime_metadata(candidate, entry.get("metadata")) else: diff --git a/logicytics/module/environment.py b/logicytics/module/environment.py index 86fe85bd..e22e047f 100644 --- a/logicytics/module/environment.py +++ b/logicytics/module/environment.py @@ -30,8 +30,8 @@ def inspect_environment() -> EnvironmentReport: is_administrator = windows_api_adapter.is_administrator() try: with registry_adapter.OpenKey( - registry_adapter.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System", + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System", ) as key: uac_enabled = bool(registry_adapter.QueryValueEx(key, "EnableLUA")[0]) except OSError: diff --git a/logicytics/module/errors.py b/logicytics/module/errors.py index fa50a7cd..21f3ee54 100644 --- a/logicytics/module/errors.py +++ b/logicytics/module/errors.py @@ -29,7 +29,4 @@ def __init__(self, code: str, capability: str, operation: str, detail: str = "") self.operation = operation action = "requested blocked" if code == "CAPABILITY_BLOCKED" else "used undeclared" suffix = f"; {detail}" if detail else "" - super().__init__( - f"{code}: collector {action} {capability} capability; " - f"operation={operation}{suffix}" - ) + super().__init__(f"{code}: collector {action} {capability} capability; operation={operation}{suffix}") diff --git a/logicytics/module/interaction.py b/logicytics/module/interaction.py index cd1f2959..5a193f12 100644 --- a/logicytics/module/interaction.py +++ b/logicytics/module/interaction.py @@ -7,11 +7,11 @@ import platform import re from collections import Counter +from collections.abc import Iterable, Mapping from dataclasses import asdict, dataclass -from datetime import datetime, timezone +from datetime import UTC, datetime from difflib import SequenceMatcher from pathlib import Path -from typing import Iterable, Mapping FLAG_DESCRIPTIONS: Mapping[str, str] = { "default": "standard collection sequentially", @@ -59,11 +59,11 @@ def _score(query: str, flag: str, description: str) -> float: def match_flag( - user_input: str, - *, - threshold: float, - model_name: str, - history: Iterable[Mapping[str, object]] = (), + user_input: str, + *, + threshold: float, + model_name: str, + history: Iterable[Mapping[str, object]] = (), ) -> FlagMatch: """Match names and descriptions, then consult prior accepted inputs when weak.""" if not isinstance(user_input, str) or not user_input.strip(): @@ -81,7 +81,11 @@ def match_flag( old_flag = item.get("matched_flag") if isinstance(old_input, str) and isinstance(old_flag, str) and old_flag in FLAG_DESCRIPTIONS: historical.append( - (SequenceMatcher(None, _normalized(user_input), _normalized(old_input)).ratio(), old_flag)) + ( + SequenceMatcher(None, _normalized(user_input), _normalized(old_input)).ratio(), + old_flag, + ) + ) if historical: history_score, history_flag = sorted(historical, key=lambda item: (-item[0], item[1]))[0] if history_score > direct_score: @@ -106,11 +110,13 @@ def load_history(path: Path) -> list[dict[str, object]]: def record_match(path: Path, match: FlagMatch) -> None: """Atomically append one compressed, local-only interaction record.""" history = load_history(path) - history.append({ - **asdict(match), - "timestamp": datetime.now(timezone.utc).isoformat(), - "device_name": platform.node() or "unknown", - }) + history.append( + { + **asdict(match), + "timestamp": datetime.now(UTC).isoformat(), + "device_name": platform.node() or "unknown", + } + ) path.parent.mkdir(parents=True, exist_ok=True) temporary = path.with_suffix(path.suffix + ".tmp") with gzip.open(temporary, "wt", encoding="utf-8") as stream: @@ -147,11 +153,7 @@ def usage_statistics(history: Iterable[Mapping[str, object]]) -> dict[str, objec return { "total_interactions": len(records), - "average_accuracy": ( - round(sum(accuracies) / len(accuracies), 6) - if accuracies - else 0.0 - ), + "average_accuracy": (round(sum(accuracies) / len(accuracies), 6) if accuracies else 0.0), "common_device": devices.most_common(1)[0][0] if devices else None, "common_input": inputs.most_common(1)[0][0] if inputs else None, "per_flag_frequency": dict(sorted(flags.items())), @@ -171,16 +173,17 @@ def write_usage_graph(path: Path, statistics: Mapping[str, object]) -> Path: count = int(counts.get(label, 0)) y = 48 + index * row_height bar_width = int(500 * count / maximum) - rows.extend(( - f'{label}', - f'', - f'{count}', - )) + rows.extend( + ( + f'{label}', + f'', + f'{count}', + ) + ) svg = ( - f'' - 'Logicytics flag usage' - + "".join(rows) + "\n" + f'' + 'Logicytics flag usage' + "".join(rows) + "\n" ) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(svg, encoding="utf-8") diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index fe7ba92f..54bee0a7 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -9,18 +9,25 @@ import sys import textwrap import traceback -from datetime import datetime, timezone +from collections.abc import Callable, Iterable, Mapping +from datetime import UTC, datetime from pathlib import Path from threading import RLock from time import perf_counter, time -from typing import Callable, Iterable, Mapping, ParamSpec, TextIO, TypeVar +from typing import ParamSpec, TextIO, TypeVar from logicytics.contracts import EventLogger from logicytics.module.configuration import LoggingSettings from logicytics.module.presentation import ( console_width as presentation_console_width, +) +from logicytics.module.presentation import ( render_alert as render_presentation_alert, +) +from logicytics.module.presentation import ( render_section as render_presentation_section, +) +from logicytics.module.presentation import ( render_step_heading as render_presentation_step_heading, ) from logicytics.module.redaction import redact_mapping, redact_text @@ -55,8 +62,8 @@ _RECORD_START = re.compile(rb"(?m)^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{3})? \|") _EVENT_NAME = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)+$") _LOGGER_LOCK = RLock() -_APPLICATION_LOGGERS: dict[Path, "ApplicationLogger"] = {} -_EVENT_LOGGERS: dict[tuple[Path, str, str | None], "FileEventLogger"] = {} +_APPLICATION_LOGGERS: dict[Path, ApplicationLogger] = {} +_EVENT_LOGGERS: dict[tuple[Path, str, str | None], FileEventLogger] = {} def _normalize_level(level: str) -> str: @@ -73,11 +80,11 @@ class ApplicationLogger(EventLogger): """Thread-safe human-readable file and colored-console event sink.""" def __init__( - self, - path: Path, - settings: LoggingSettings, - *, - console: TextIO | None = None, + self, + path: Path, + settings: LoggingSettings, + *, + console: TextIO | None = None, ) -> None: """Initialize a bounded application logger with explicit file and console sinks.""" self.path = path.resolve() @@ -94,11 +101,7 @@ def _prepare_file(self) -> None: self.path.unlink() cutoff = time() - self.settings.retention_days * 86400 for candidate in self.path.parent.glob("Logicytics*.log"): - if ( - candidate != self.path - and candidate.is_file() - and candidate.stat().st_mtime < cutoff - ): + if candidate != self.path and candidate.is_file() and candidate.stat().st_mtime < cutoff: candidate.unlink() self._truncate_file() @@ -110,7 +113,7 @@ def _truncate_file(self) -> None: stream.readline() retained = stream.read() record = _RECORD_START.search(retained) - retained = retained[record.start():] if record is not None else b"" + retained = retained[record.start() :] if record is not None else b"" self.path.write_bytes(retained) @staticmethod @@ -119,15 +122,9 @@ def _rows(level: str, source: str, message: str) -> tuple[str, ...]: timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3] source_column = source.removeprefix("logicytics.") if len(source_column) > _SOURCE_WIDTH: - source_column = source_column[:_SOURCE_WIDTH - 3] + "..." - prefix = ( - f"{timestamp:<{_TIME_WIDTH}} | {level:<{_SEVERITY_WIDTH}} | " - f"{source_column:<{_SOURCE_WIDTH}} | " - ) - continuation = ( - f"{'':<{_TIME_WIDTH}} | {'':<{_SEVERITY_WIDTH}} | " - f"{'':<{_SOURCE_WIDTH}} | " - ) + source_column = source_column[: _SOURCE_WIDTH - 3] + "..." + prefix = f"{timestamp:<{_TIME_WIDTH}} | {level:<{_SEVERITY_WIDTH}} | {source_column:<{_SOURCE_WIDTH}} | " + continuation = f"{'':<{_TIME_WIDTH}} | {'':<{_SEVERITY_WIDTH}} | {'':<{_SOURCE_WIDTH}} | " available = max(_FILE_LOG_LINE_WIDTH - len(prefix), 1) wrapped = [ segment @@ -138,7 +135,8 @@ def _rows(level: str, source: str, message: str) -> tuple[str, ...]: width=available, break_long_words=True, break_on_hyphens=False, - ) or [""] + ) + or [""] ) ] first = prefix + wrapped[0] @@ -166,7 +164,7 @@ def _console_rows(cls, marker: str, message: str) -> tuple[str, ...]: width = cls._console_width() rows: list[str] = [] for index, raw_line in enumerate(message.expandtabs(4).splitlines() or [""]): - indentation = raw_line[:len(raw_line) - len(raw_line.lstrip())] + indentation = raw_line[: len(raw_line) - len(raw_line.lstrip())] remaining = raw_line.lstrip().rstrip() current_prefix = (prefix if index == 0 else continuation) + indentation while len(remaining) > max(width - len(current_prefix), 1): @@ -186,11 +184,7 @@ def _color_console_text(row: str, text_color: str) -> str: indentation_length = len(row) - len(row.lstrip()) detail = row[indentation_length:] if detail.startswith("> "): - return ( - f"{text_color}{_BOLD}{row[:indentation_length]}" - f"{_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET}" - f"{text_color}{_BOLD}{detail[1:]}" - ) + return f"{text_color}{_BOLD}{row[:indentation_length]}{_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET}{text_color}{_BOLD}{detail[1:]}" return f"{text_color}{_BOLD}{row}" @staticmethod @@ -216,19 +210,16 @@ def _message_lines(cls, message: str) -> tuple[str, ...]: return tuple(rows) if isinstance(parsed, list): rows = ["Structured details"] - rows.extend( - f"Item {index}: {cls._console_value(item)}" - for index, item in enumerate(parsed, start=1) - ) + rows.extend(f"Item {index}: {cls._console_value(item)}" for index, item in enumerate(parsed, start=1)) return tuple(rows) return (cls._console_message(safe_message),) @classmethod def _console_fields( - cls, - fields: Mapping[str, object], - *, - compact_configuration_hash: bool = False, + cls, + fields: Mapping[str, object], + *, + compact_configuration_hash: bool = False, ) -> tuple[str, ...]: """Render structured fields as readable labels instead of JSON fragments.""" rows: list[str] = [] @@ -313,7 +304,7 @@ def _console_value(cls, value: object) -> str: return cls._console_value(parsed) return str(value) - def event(self, level: str, message: str, **fields: int | float | str) -> None: + def event(self, level: str, message: str, **fields: float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" normalized = _normalize_level(level) minimum_level = _normalize_level(self.settings.level) @@ -348,18 +339,12 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: if self.settings.color_enabled and self.console.isatty(): marker_prefix = f" {marker} " first_row = self._color_console_text( - console_rows[0][len(marker_prefix):], + console_rows[0][len(marker_prefix) :], text_color, ) - colored_rows = ( - f"{marker_color}{_BOLD}{marker_prefix}{_RESET}" - f"{first_row}" - ) + colored_rows = f"{marker_color}{_BOLD}{marker_prefix}{_RESET}{first_row}" if len(console_rows) > 1: - colored_rows += "\n" + "\n".join( - self._color_console_text(row, text_color) - for row in console_rows[1:] - ) + colored_rows += "\n" + "\n".join(self._color_console_text(row, text_color) for row in console_rows[1:]) self.console.write(f"{colored_rows}{_RESET}\n") else: self.console.write("\n".join(console_rows) + "\n") @@ -381,10 +366,10 @@ def separator(self) -> None: @classmethod def render_section( - cls, - console: TextIO, - title: str, - lines: Iterable[str], + cls, + console: TextIO, + title: str, + lines: Iterable[str], ) -> None: """Render a plain, indented console section for startup and fallback paths.""" render_presentation_section( @@ -397,10 +382,10 @@ def render_section( @classmethod def render_alert( - cls, - console: TextIO, - title: str, - lines: Iterable[str], + cls, + console: TextIO, + title: str, + lines: Iterable[str], ) -> None: """Render a bordered startup or error alert using the shared logger presentation.""" render_presentation_alert( @@ -445,7 +430,7 @@ def error(self, message: str) -> None: """Render one concise argument error and its usage without raw argparse output.""" usage = self.format_usage().strip() if usage.lower().startswith("usage:"): - usage = usage[len("usage:"):].strip() + usage = usage[len("usage:") :].strip() ApplicationLogger.render_section( sys.stderr, "Command-line error", @@ -455,21 +440,16 @@ def error(self, message: str) -> None: def get_application_logger( - path: Path, - settings: LoggingSettings, - *, - console: TextIO | None = None, + path: Path, + settings: LoggingSettings, + *, + console: TextIO | None = None, ) -> ApplicationLogger: """Return one configured logger instance per canonical application log path.""" resolved = path.resolve() with _LOGGER_LOCK: logger = _APPLICATION_LOGGERS.get(resolved) - if ( - logger is None - or logger.settings != settings - or console is not None - or (console is None and logger.console is not sys.stderr) - ): + if logger is None or logger.settings != settings or console is not None or (console is None and logger.console is not sys.stderr): logger = ApplicationLogger(resolved, settings, console=console) if console is None: _APPLICATION_LOGGERS[resolved] = logger @@ -487,11 +467,11 @@ def __init__(self, path: Path, *, run_id: str, collector_id: str | None = None) self._event_lock = RLock() self.path.parent.mkdir(parents=True, exist_ok=True) - def event(self, level: str, message: str, **fields: int | float | str) -> None: + def event(self, level: str, message: str, **fields: float | str) -> None: """Write a timestamped, structured event without relying on global handlers.""" normalized = _normalize_level(level) payload: dict[str, object] = { - "at": datetime.now(timezone.utc).isoformat(), + "at": datetime.now(UTC).isoformat(), "level": normalized.lower(), "message": redact_text(message), "run_id": self.run_id, @@ -500,14 +480,11 @@ def event(self, level: str, message: str, **fields: int | float | str) -> None: payload["collector_id"] = self.collector_id if fields: payload["fields"] = redact_mapping(fields) - with self._event_lock: - with self.path.open("a", encoding="utf-8") as stream: - stream.write(json.dumps(payload, sort_keys=True) + "\n") + with self._event_lock, self.path.open("a", encoding="utf-8") as stream: + stream.write(json.dumps(payload, sort_keys=True) + "\n") -def get_event_logger( - path: Path, *, run_id: str, collector_id: str | None = None -) -> FileEventLogger: +def get_event_logger(path: Path, *, run_id: str, collector_id: str | None = None) -> FileEventLogger: """Return the process-local singleton for one canonical engine or collector channel.""" identity = (path.resolve(), run_id, collector_id) with _LOGGER_LOCK: @@ -518,10 +495,7 @@ def get_event_logger( return logger -def timed( - logger: EventLogger, *, - level: str = "info" -) -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: +def timed(logger: EventLogger, *, level: str = "info") -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so structured start, finish, error, and duration events are written.""" def decorate(function: Callable[Parameters, Result]) -> Callable[Parameters, Result]: @@ -542,8 +516,12 @@ def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: error_type=type(error).__name__, ) raise - logger.event(level, "function_finished", function=function.__qualname__, - duration_seconds=round(perf_counter() - started, 6)) + logger.event( + level, + "function_finished", + function=function.__qualname__, + duration_seconds=round(perf_counter() - started, 6), + ) return value return wrapped @@ -551,18 +529,14 @@ def wrapped(*args: Parameters.args, **kwargs: Parameters.kwargs) -> Result: return decorate -def raise_logged(logger: EventLogger, exception_type: type[Exception], message: str, - **fields: int | float | str) -> None: +def raise_logged(logger: EventLogger, exception_type: type[Exception], message: str, **fields: float | str) -> None: """Record a structured exception event, then raise the requested exception type.""" logger.event("exception", message, exception_type=exception_type.__name__, **fields) raise exception_type(message) def deprecated( - logger: EventLogger, *, - removal_version: str, - reason: str, - include_stack: bool = False + logger: EventLogger, *, removal_version: str, reason: str, include_stack: bool = False ) -> Callable[[Callable[Parameters, Result]], Callable[Parameters, Result]]: """Decorate a function so each invocation emits a structured deprecation warning.""" diff --git a/logicytics/module/maintenance.py b/logicytics/module/maintenance.py index 199fdfec..2b0ca429 100644 --- a/logicytics/module/maintenance.py +++ b/logicytics/module/maintenance.py @@ -10,16 +10,15 @@ import sys import tomllib import urllib.request +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Any, Mapping, TypedDict +from typing import Any, TypedDict from logicytics.module.configuration import MaintenanceSettings _SHA256 = re.compile(r"^[0-9a-f]{64}$") -_VERSION = re.compile( - r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+)|((?:a|b|rc|\.dev)\d+))?$" -) +_VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+)|((?:a|b|rc|\.dev)\d+))?$") _MAXIMUM_MANIFEST_BYTES = 2 * 1024 * 1024 _EXCLUDED_PARTS = frozenset( { @@ -88,25 +87,12 @@ def _parse_manifest(payload: object, source: str) -> IntegrityManifest: raise ValueError("integrity manifest files must be an object") validated: dict[str, str] = {} for name, digest in files.items(): - if ( - not isinstance(name, str) - or not isinstance(digest, str) - or _SHA256.fullmatch(digest) is None - ): + if not isinstance(name, str) or not isinstance(digest, str) or _SHA256.fullmatch(digest) is None: raise ValueError("integrity manifest file entries require path and SHA-256 strings") path = PurePosixPath(name) - if ( - name == "." - or path.is_absolute() - or ".." in path.parts - or path.as_posix() != name - or "\\" in name - ): + if name == "." or path.is_absolute() or ".." in path.parts or path.as_posix() != name or "\\" in name: raise ValueError(f"integrity manifest path is unsafe: {name!r}") - if any( - part in _EXCLUDED_PARTS or part.startswith(".") and part != ".github" - for part in path.parts - ): + if any(part in _EXCLUDED_PARTS or part.startswith(".") and part != ".github" for part in path.parts): raise ValueError(f"integrity manifest path is excluded: {name!r}") validated[name] = digest return IntegrityManifest(version, dict(sorted(validated.items())), source) @@ -133,9 +119,7 @@ def fetch_remote_manifest(settings: MaintenanceSettings) -> IntegrityManifest | return _parse_manifest(raw, settings.remote_manifest_url) -def load_local_manifest( - project_root: Path, settings: MaintenanceSettings -) -> IntegrityManifest | None: +def load_local_manifest(project_root: Path, settings: MaintenanceSettings) -> IntegrityManifest | None: """Load the configured project-owned manifest without leaving the repository.""" path = (project_root / settings.local_manifest_path).resolve() try: @@ -171,24 +155,19 @@ def project_files(project_root: Path, settings: MaintenanceSettings) -> tuple[Pa return tuple(sorted(files)) -def build_manifest( - project_root: Path, settings: MaintenanceSettings, version: str -) -> IntegrityManifest: +def build_manifest(project_root: Path, settings: MaintenanceSettings, version: str) -> IntegrityManifest: """Create a deterministic required-file manifest from eligible project files.""" if _VERSION.fullmatch(version) is None: raise ValueError("next version must use semantic versioning") root = project_root.resolve() - files = { - path.relative_to(root).as_posix(): sha256_path(path) - for path in project_files(root, settings) - } + files = {path.relative_to(root).as_posix(): sha256_path(path) for path in project_files(root, settings)} return IntegrityManifest(version, files, "generated") def write_local_manifest( - project_root: Path, - settings: MaintenanceSettings, - manifest: IntegrityManifest, + project_root: Path, + settings: MaintenanceSettings, + manifest: IntegrityManifest, ) -> Path: """Atomically publish an explicitly approved local manifest update.""" path = (project_root / settings.local_manifest_path).resolve() @@ -208,30 +187,19 @@ def write_local_manifest( def compare_files( - project_root: Path, - settings: MaintenanceSettings, - manifest: IntegrityManifest, + project_root: Path, + settings: MaintenanceSettings, + manifest: IntegrityManifest, ) -> dict[str, list[str]]: """Report exact missing, modified, extra, and unchanged project files.""" root = project_root.resolve() - current = { - path.relative_to(root).as_posix(): sha256_path(path) - for path in project_files(root, settings) - } + current = {path.relative_to(root).as_posix(): sha256_path(path) for path in project_files(root, settings)} expected = dict(manifest.files) return { "missing": sorted(set(expected) - set(current)), - "modified": sorted( - name - for name in set(expected).intersection(current) - if expected[name] != current[name] - ), + "modified": sorted(name for name in set(expected).intersection(current) if expected[name] != current[name]), "extra": sorted(set(current) - set(expected)), - "unchanged": sorted( - name - for name in set(expected).intersection(current) - if expected[name] == current[name] - ), + "unchanged": sorted(name for name in set(expected).intersection(current) if expected[name] == current[name]), } @@ -261,10 +229,7 @@ def parsed(value: str) -> tuple[int, int, int, int, tuple[tuple[int, int | str], label, number = pep_match.groups() identifiers = ((0, {".dev": 0, "a": 1, "b": 2, "rc": 3}[label]), (0, int(number))) elif prerelease is not None: - identifiers = tuple( - (0, int(identifier)) if identifier.isdigit() else (1, identifier) - for identifier in prerelease.split(".") - ) + identifiers = tuple((0, int(identifier)) if identifier.isdigit() else (1, identifier) for identifier in prerelease.split(".")) is_stable = prerelease is None and pep_prerelease is None return int(major), int(minor), int(patch), 1 if is_stable else 0, identifiers @@ -277,13 +242,7 @@ def python_support(settings: MaintenanceSettings) -> dict[str, str]: running = (sys.version_info.major, sys.version_info.minor) minimum = tuple(map(int, settings.minimum_python.split("."))) recommended = tuple(map(int, settings.recommended_python.split("."))) - status = ( - "incompatible" - if running < minimum - else "recommended" - if running == recommended - else "supported" - ) + status = "incompatible" if running < minimum else "recommended" if running == recommended else "supported" return { "running": f"{running[0]}.{running[1]}", "minimum": settings.minimum_python, @@ -292,9 +251,7 @@ def python_support(settings: MaintenanceSettings) -> dict[str, str]: } -def maintenance_diagnostics( - project_root: Path, settings: MaintenanceSettings -) -> dict[str, object]: +def maintenance_diagnostics(project_root: Path, settings: MaintenanceSettings) -> dict[str, object]: """Resolve optional remote/local integrity evidence without changing collection state.""" remote = fetch_remote_manifest(settings) local_manifest = load_local_manifest(project_root, settings) @@ -304,15 +261,9 @@ def maintenance_diagnostics( "local_version": local_version(project_root), "manifest_source": selected.source if selected else None, "manifest_version": selected.version if selected else None, - "version_status": ( - compare_versions(local_version(project_root), selected.version) - if selected - else "unconfigured" - ), + "version_status": (compare_versions(local_version(project_root), selected.version) if selected else "unconfigured"), "files": ( - compare_files(project_root, settings, selected) - if selected - else {"missing": [], "modified": [], "extra": [], "unchanged": []} + compare_files(project_root, settings, selected) if selected else {"missing": [], "modified": [], "extra": [], "unchanged": []} ), "remote_enabled": remote is not None, } @@ -320,6 +271,7 @@ def maintenance_diagnostics( class DeveloperChecks(TypedDict): """Static repository hygiene findings returned by developer checks.""" + naming_violations: list[str] misplaced_python: list[str] missing_module_docstrings: list[str] @@ -351,10 +303,9 @@ def developer_checks(project_root: Path, settings: MaintenanceSettings) -> Devel missing_docstrings.append(relative.as_posix()) public_features = [ - node.name for node in tree.body - if isinstance( - node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef) - ) and not node.name.startswith("_") + node.name + for node in tree.body + if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_") ] if len(public_features) > 12: diff --git a/logicytics/module/manifest.py b/logicytics/module/manifest.py index 1e0c26ad..03296248 100644 --- a/logicytics/module/manifest.py +++ b/logicytics/module/manifest.py @@ -6,10 +6,11 @@ import json import os import platform +from collections.abc import Mapping from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path, PurePosixPath -from typing import Any, Mapping +from typing import Any from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus from logicytics.module.redaction import redact_mapping, redact_text @@ -20,7 +21,7 @@ def utc_now() -> str: """Return a stable UTC timestamp for manifests and records.""" - return datetime.now(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() @dataclass(slots=True) @@ -135,15 +136,15 @@ class RunManifest: @classmethod def create( - cls, - run_id: str, - request: Mapping[str, Any], - configuration: Mapping[str, Any], - collector_sources: list[tuple[str, Path]], - *, - parent_run_id: str | None = None, - plan_fingerprint: str | None = None, - ) -> "RunManifest": + cls, + run_id: str, + request: Mapping[str, Any], + configuration: Mapping[str, Any], + collector_sources: list[tuple[str, Path]], + *, + parent_run_id: str | None = None, + plan_fingerprint: str | None = None, + ) -> RunManifest: """Create the initial planned manifest before collection begins.""" return cls( run_id=run_id, @@ -151,8 +152,7 @@ def create( status=RunStatus.PLANNED, request=request, configuration=configuration, - collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in - collector_sources], + collectors=[CollectorRecord(id=collector_id, source=str(source)) for collector_id, source in collector_sources], resolved_plan=tuple(collector_id for collector_id, _ in collector_sources), plan_fingerprint=plan_fingerprint, action="rerun" if parent_run_id is not None else "run", @@ -180,17 +180,16 @@ def finalize_status(self) -> None: self.cancellation_requested = "cancelled" in statuses self.skipped_collectors = [record.id for record in self.collectors if record.status == "skipped"] self.errors = [ - {"collector_id": record.id, "status": record.status, "message": error} - for record in self.collectors - for error in record.errors + {"collector_id": record.id, "status": record.status, "message": error} for record in self.collectors for error in record.errors ] self.finished_at = utc_now() self.total_artifact_bytes = sum(artifact.size_bytes for artifact in self.artifact_list()) def artifact_list(self) -> tuple[Artifact, ...]: """Return all registered artifacts reconstructed from the manifest.""" - return tuple(Artifact.from_dict({key: value for key, value in item.items() if key != "producer_status"}) - for item in self.artifact_catalog()) + return tuple( + Artifact.from_dict({key: value for key, value in item.items() if key != "producer_status"}) for item in self.artifact_catalog() + ) def artifact_catalog(self) -> tuple[dict[str, Any], ...]: """Build the globally unique, ownership-validated run-wide evidence catalog.""" @@ -201,19 +200,19 @@ def artifact_catalog(self) -> tuple[dict[str, Any], ...]: for item in record.artifacts: artifact = Artifact.from_dict(item) if artifact.collector_id != record.id: - raise ValueError(f"manifest artifact collector ownership is invalid: {artifact.relative_path}") + raise ValueError("manifest artifact collector ownership is invalid: " + artifact.relative_path) relative = PurePosixPath(artifact.relative_path) if ( - "\\" in artifact.relative_path - or relative.is_absolute() - or ".." in relative.parts - or relative.as_posix() != artifact.relative_path - or len(relative.parts) < 2 - or relative.parts[0] != record.id.replace(".", "_") + "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != record.id.replace(".", "_") ): raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") if artifact.name != relative.name: - raise ValueError(f"manifest artifact name does not match its registered path: {artifact.name}") + raise ValueError("manifest artifact name does not match its registered path: " + artifact.name) if artifact.id in artifact_ids: raise ValueError(f"manifest contains duplicate artifact id: {artifact.id}") if artifact.relative_path in artifact_paths: @@ -226,13 +225,12 @@ def artifact_catalog(self) -> tuple[dict[str, Any], ...]: def to_dict(self) -> dict[str, Any]: """Produce JSON-safe manifest data.""" if ( - not isinstance(self.manifest_schema_version, int) - or isinstance(self.manifest_schema_version, bool) - or self.manifest_schema_version != MANIFEST_SCHEMA_VERSION + not isinstance(self.manifest_schema_version, int) + or isinstance(self.manifest_schema_version, bool) + or self.manifest_schema_version != MANIFEST_SCHEMA_VERSION ): raise ValueError( - f"unsupported run manifest schema_version {self.manifest_schema_version!r}; " - f"expected {MANIFEST_SCHEMA_VERSION}" + f"unsupported run manifest schema_version {self.manifest_schema_version!r}; expected {MANIFEST_SCHEMA_VERSION}" ) data = asdict(self) data["status"] = self.status.value diff --git a/logicytics/module/mod_runner.py b/logicytics/module/mod_runner.py index 506818c4..f484583b 100644 --- a/logicytics/module/mod_runner.py +++ b/logicytics/module/mod_runner.py @@ -19,10 +19,7 @@ def main(argv: list[str] | None = None) -> int: ApplicationLogger.render_section( sys.stderr, "Python MOD runner error", - ( - "The runner requires script, workspace, collector ID, " - "capabilities, and blocked capabilities.", - ), + ("The runner requires script, workspace, collector ID, capabilities, and blocked capabilities.",), ) return 2 script = Path(arguments[0]).resolve() diff --git a/logicytics/module/modes.py b/logicytics/module/modes.py index 753ea1ab..8339dd4f 100644 --- a/logicytics/module/modes.py +++ b/logicytics/module/modes.py @@ -2,10 +2,11 @@ from __future__ import annotations +from collections.abc import Iterable, Mapping from dataclasses import dataclass from enum import Enum from types import MappingProxyType -from typing import Iterable, TypedDict, Mapping +from typing import TypedDict from logicytics.contracts import CollectorKind from logicytics.module.discovery import CollectorCandidate @@ -13,6 +14,7 @@ class ModeMatrixCollector(TypedDict): """Serialized mode membership and validation details for one collector.""" + id: str kind: str valid: bool @@ -24,6 +26,7 @@ class ModeMatrixCollector(TypedDict): class ModeMatrixMode(TypedDict): """Serialized execution mode definition and its selected collectors.""" + name: str description: str strategy: str @@ -33,6 +36,7 @@ class ModeMatrixMode(TypedDict): class ModeMatrix(TypedDict): """Versioned machine-readable matrix of modes and collector memberships.""" + schema_version: int modes: list[ModeMatrixMode] collectors: list[ModeMatrixCollector] @@ -89,22 +93,22 @@ class ExecutionMode: ), ) -EXECUTION_MODES: Mapping[str, ExecutionMode] = MappingProxyType( - {mode.name: mode for mode in _MODE_LIST} +EXECUTION_MODES: Mapping[str, ExecutionMode] = MappingProxyType({mode.name: mode for mode in _MODE_LIST}) +LEGACY_MODE_ALIASES: Mapping[str, str] = MappingProxyType( + { + "default_mode": "standard", + "threaded": "balanced", + "minimal": "quick", + "depth": "thorough", + "modded": "extensions", + "performance_check": "performance", + } ) -LEGACY_MODE_ALIASES: Mapping[str, str] = MappingProxyType({ - "default_mode": "standard", - "threaded": "balanced", - "minimal": "quick", - "depth": "thorough", - "modded": "extensions", - "performance_check": "performance", -}) def resolve_execution_mode( - selected: str | None, - legacy_flags: Mapping[str, bool], + selected: str | None, + legacy_flags: Mapping[str, bool], ) -> ExecutionMode | None: """Resolve a user-facing name or exactly one parser-exclusive legacy alias.""" aliases = [name for flag, name in LEGACY_MODE_ALIASES.items() if legacy_flags.get(flag, False)] @@ -140,40 +144,27 @@ def _candidate_modes(candidate: CollectorCandidate) -> tuple[str, ...]: def mode_matrix( - candidates: Iterable[CollectorCandidate] = (), + candidates: Iterable[CollectorCandidate] = (), ) -> ModeMatrix: """Return the versioned mode definitions and complete collector inclusion matrix.""" aliases_by_mode = { - name: sorted( - f"--{flag.removesuffix('_mode').replace('_', '-')}" - for flag, target in LEGACY_MODE_ALIASES.items() - if target == name - ) + name: sorted(f"--{flag.removesuffix('_mode').replace('_', '-')}" for flag, target in LEGACY_MODE_ALIASES.items() if target == name) for name in EXECUTION_MODES } ordered_candidates = sorted( candidates, key=lambda candidate: ( - candidate.metadata.id - if candidate.metadata is not None - else candidate.selection_id, + candidate.metadata.id if candidate.metadata is not None else candidate.selection_id, str(candidate.path), ), ) collector_rows: list[ModeMatrixCollector] = [] - memberships: dict[str, list[str]] = { - name: [] - for name in EXECUTION_MODES - } + memberships: dict[str, list[str]] = {name: [] for name in EXECUTION_MODES} for candidate in ordered_candidates: - collector_id = ( - candidate.metadata.id - if candidate.metadata is not None - else candidate.selection_id - ) + collector_id = candidate.metadata.id if candidate.metadata is not None else candidate.selection_id assigned_modes = _candidate_modes(candidate) for mode_name in assigned_modes: @@ -249,10 +240,7 @@ def render_mode_matrix_markdown(matrix: Mapping[str, object]) -> str: else: selection = "mode selected" - rows.append( - f"| `{collector.get('id', '')}` | `{collector.get('kind', '')}` | " - f"{'yes' if valid else 'no'} | {modes} | {selection} |" - ) + rows.append(f"| `{collector.get('id', '')}` | `{collector.get('kind', '')}` | {'yes' if valid else 'no'} | {modes} | {selection} |") rows.append("") return "\n".join(rows) diff --git a/logicytics/module/output_layout.py b/logicytics/module/output_layout.py index bbd22d21..221f68ef 100644 --- a/logicytics/module/output_layout.py +++ b/logicytics/module/output_layout.py @@ -39,12 +39,12 @@ def ensure_output_layout(output_root: Path) -> OutputLayout: """Create the complete stable output tree for a state-changing application action.""" layout = output_layout(output_root) for directory in ( - layout.data, - layout.logs, - layout.debug_logs, - layout.performance_logs, - layout.packages, - layout.hashes, + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, ): directory.mkdir(parents=True, exist_ok=True) return layout diff --git a/logicytics/module/packaging.py b/logicytics/module/packaging.py index 70af933a..8c0f1fa6 100644 --- a/logicytics/module/packaging.py +++ b/logicytics/module/packaging.py @@ -7,9 +7,9 @@ import os import re import zipfile -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path, PurePosixPath -from typing import TYPE_CHECKING, IO +from typing import IO, TYPE_CHECKING from logicytics.contracts import Artifact from logicytics.module.artifacts import sha256_file @@ -36,7 +36,7 @@ def _package_filename(manifest: RunManifest) -> str: raise ValueError("package requested_at must be a valid timestamp") from error if requested_at.tzinfo is None: raise ValueError("package requested_at must include a timezone") - timestamp = requested_at.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + timestamp = requested_at.astimezone(UTC).strftime("%Y%m%dT%H%M%S.%fZ") return f"{manifest.action}-{timestamp}-{manifest.run_id}.zip" @@ -105,13 +105,13 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ relative = PurePosixPath(artifact.relative_path) owner = artifact.collector_id.replace(".", "_") if ( - not artifact.relative_path - or "\\" in artifact.relative_path - or relative.is_absolute() - or ".." in relative.parts - or relative.as_posix() != artifact.relative_path - or len(relative.parts) < 2 - or relative.parts[0] != owner + not artifact.relative_path + or "\\" in artifact.relative_path + or relative.is_absolute() + or ".." in relative.parts + or relative.as_posix() != artifact.relative_path + or len(relative.parts) < 2 + or relative.parts[0] != owner ): raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") source = artifact_root.joinpath(*relative.parts) @@ -120,9 +120,7 @@ def _artifact_sources(run_directory: Path, manifest: RunManifest) -> list[tuple[ try: source.resolve(strict=True).relative_to(artifact_root / owner) except (OSError, ValueError) as error: - raise ValueError( - f"manifest artifact escapes its collector-owned store: {artifact.relative_path}" - ) from error + raise ValueError(f"manifest artifact escapes its collector-owned store: {artifact.relative_path}") from error archive_name = _artifact_archive_name(artifact) if archive_name in archive_names: raise ValueError(f"manifest contains duplicate artifact path: {artifact.relative_path}") @@ -140,10 +138,7 @@ def _artifact_archive_name(artifact: Artifact) -> str: def _artifact_checksum_catalog(manifest: RunManifest) -> str: """Return a deterministic SHA-256 catalog for every packaged evidence member.""" - entries = sorted( - (_artifact_archive_name(artifact), artifact.sha256) - for artifact in manifest.artifact_list() - ) + entries = sorted((_artifact_archive_name(artifact), artifact.sha256) for artifact in manifest.artifact_list()) return "".join(f"{digest} {archive_name}\n" for archive_name, digest in entries) @@ -226,10 +221,10 @@ def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: s def _package_mod_artifacts( - package_directory: Path, - hash_directory: Path, - manifest: RunManifest, - artifact_sources: list[tuple[Path, str]], + package_directory: Path, + hash_directory: Path, + manifest: RunManifest, + artifact_sources: list[tuple[Path, str]], ) -> dict[str, str]: """Publish MODS evidence in a separately named atomic package and sidecar.""" mod_sources = [ @@ -248,10 +243,7 @@ def _package_mod_artifacts( temporary_hash = hash_path.with_suffix(".sha256.tmp") mod_catalog = { "run_id": manifest.run_id, - "artifacts": [ - item for item in manifest.artifact_catalog() - if str(item.get("collector_id", "")).startswith("mod.") - ], + "artifacts": [item for item in manifest.artifact_catalog() if str(item.get("collector_id", "")).startswith("mod.")], } expected_names = {"metadata/mods.json", *(archive_name for _, archive_name in mod_sources)} try: @@ -301,7 +293,11 @@ def _package_mod_artifacts( temporary_package.unlink() if temporary_hash.exists(): temporary_hash.unlink() - return {"mods_path": str(package_path), "mods_sha256_path": str(hash_path), "mods_sha256": digest} + return { + "mods_path": str(package_path), + "mods_sha256_path": str(hash_path), + "mods_sha256": digest, + } def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: Path) -> tuple[Path, Path]: @@ -375,6 +371,6 @@ def package_manifest(run_directory: Path, manifest: RunManifest, manifest_path: return package_path, hash_path -def package_run(outcome: "RunOutcome") -> tuple[Path, Path]: +def package_run(outcome: RunOutcome) -> tuple[Path, Path]: """Compatibility wrapper for callers holding a complete runtime outcome.""" return package_manifest(outcome.run_directory, outcome.manifest, outcome.manifest_path) diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index 1a86e255..c3cfbda0 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -5,8 +5,7 @@ import hashlib import json import sys -from dataclasses import asdict -from dataclasses import dataclass +from dataclasses import asdict, dataclass from types import MappingProxyType from logicytics.contracts import Capability, CollectorKind, RunRequest @@ -14,12 +13,14 @@ from logicytics.module.environment import inspect_environment from logicytics.module.errors import PlanError, PreflightError -BUILTIN_PROFILES = MappingProxyType({ - "minimal": "Essential local system, memory, and storage inventory only.", - "standard": "Shipped core collectors explicitly declaring standard membership.", - "deep": "Extended declared inventory, subject to declared-capability blocking policy.", - "offline": "Declared local-only inventory with network and packet access prohibited.", -}) +BUILTIN_PROFILES = MappingProxyType( + { + "minimal": "Essential local system, memory, and storage inventory only.", + "standard": "Shipped core collectors explicitly declaring standard membership.", + "deep": "Extended declared inventory, subject to declared-capability blocking policy.", + "offline": "Declared local-only inventory with network and packet access prohibited.", + } +) _OFFLINE_PROHIBITED_CAPABILITIES = frozenset({Capability.NETWORK, Capability.PACKET_CAPTURE}) @@ -101,10 +102,10 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: candidate for candidate in report.invalid if ( - candidate.kind is CollectorKind.CORE or - candidate.selection_id in request.include or - (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) or - (request.enable_mods and candidate.kind is CollectorKind.MOD) + candidate.kind is CollectorKind.CORE + or candidate.selection_id in request.include + or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) + or (request.enable_mods and candidate.kind is CollectorKind.MOD) ) ] if invalid_selected: @@ -116,11 +117,7 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: unknown_includes = sorted(set(request.include) - set(valid)) if unknown_includes: raise PlanError(f"requested collectors are unavailable: {', '.join(unknown_includes)}") - selected = { - collector_id: candidate - for collector_id, candidate in valid.items() - if _selected_by_request(candidate, request) - } + selected = {collector_id: candidate for collector_id, candidate in valid.items() if _selected_by_request(candidate, request)} pending_dependencies = list(selected) while pending_dependencies: collector_id = pending_dependencies.pop() @@ -133,13 +130,9 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if dependency is None: raise PlanError(f"selected collector {collector_id} depends on unavailable collector {dependency_id}") assert dependency.metadata is not None - if dependency.kind is CollectorKind.PLUGIN and not ( - request.enable_plugins or dependency_id in request.include - ): + if dependency.kind is CollectorKind.PLUGIN and not (request.enable_plugins or dependency_id in request.include): raise PlanError(f"plugin dependency {dependency_id} must be explicitly selected or plugins enabled") - if dependency.kind is CollectorKind.MOD and not ( - request.enable_mods or dependency_id in request.include - ): + if dependency.kind is CollectorKind.MOD and not (request.enable_mods or dependency_id in request.include): raise PlanError(f"mod dependency {dependency_id} must be explicitly selected or mods enabled") if dependency.metadata.sensitive_data_categories and dependency_id not in request.include: raise PlanError(f"sensitive dependency {dependency_id} must be explicitly selected") @@ -149,26 +142,22 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: policy_errors: list[str] = [] blocked_capabilities_for_request: set[Capability] = set() for candidate in sorted( - selected.values(), - key=lambda item: item.metadata.id if item.metadata else "", + selected.values(), + key=lambda item: item.metadata.id if item.metadata else "", ): assert candidate.metadata is not None if request.profile == "offline": prohibited = set(candidate.metadata.capabilities).intersection(_OFFLINE_PROHIBITED_CAPABILITIES) if prohibited: names = ", ".join(sorted(capability.value for capability in prohibited)) - policy_errors.append( - f"{candidate.metadata.id}: offline profile prohibits network-capable collector: {names}" - ) + policy_errors.append(f"{candidate.metadata.id}: offline profile prohibits network-capable collector: {names}") if sys.platform not in candidate.metadata.supported_platforms: policy_errors.append(f"{candidate.metadata.id}: does not support {sys.platform}") blocked_capabilities = set(candidate.metadata.capabilities).intersection(request.blocked_capabilities) if blocked_capabilities: blocked_capabilities_for_request.update(blocked_capabilities) required = ", ".join(sorted(capability.value for capability in blocked_capabilities)) - policy_errors.append( - f"{candidate.metadata.id}: capability blocked by policy: {required}" - ) + policy_errors.append(f"{candidate.metadata.id}: capability blocked by policy: {required}") if policy_errors: details = "\n".join(f"- {error}" for error in policy_errors) hint = "" @@ -194,9 +183,6 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: elevated_collectors.sort() if elevated_collectors and inspect_environment().is_administrator is not True: - raise PlanError( - "selected collectors require an administrator account: " - + ", ".join(elevated_collectors) - ) + raise PlanError("selected collectors require an administrator account: " + ", ".join(elevated_collectors)) ordered = _topological_order(selected) return RunPlan(request=request, collectors=ordered, fingerprint=_fingerprint(request, ordered)) diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index ae502b1a..136f49f5 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -9,7 +9,6 @@ from logicytics.module.redaction import redact_text - _DEFAULT_CONSOLE_WIDTH = 82 _MIN_CONSOLE_WIDTH = 60 _RIGHT_EDGE_MARGIN = 4 @@ -62,10 +61,10 @@ def render_banner(console: TextIO, *, width: ConsoleWidth = terminal_width) -> N def render_step_heading( - console: TextIO, - title: str, - *, - width: ConsoleWidth = console_width, + console: TextIO, + title: str, + *, + width: ConsoleWidth = console_width, ) -> None: """Separate a lifecycle phase with a titled ASCII rule and breathing room.""" console.write(f"{_heading(title, width=width)}\n\n") @@ -78,13 +77,13 @@ def _alert_symbols() -> tuple[str, str, str, str, str, str, str, str]: def render_section( - console: TextIO, - title: str, - lines: Iterable[str], - *, - message_lines: MessageLines = _plain_message_lines, - width: ConsoleWidth = console_width, - color_enabled: bool = False, + console: TextIO, + title: str, + lines: Iterable[str], + *, + message_lines: MessageLines = _plain_message_lines, + width: ConsoleWidth = console_width, + color_enabled: bool = False, ) -> None: """Render the shared indented, wrapped console section presentation.""" rows = [_heading(title, width=width), ""] @@ -92,7 +91,7 @@ def render_section( for line in lines: for raw_row in line.splitlines() or [""]: safe_row = redact_text(raw_row) - indentation = safe_row[:len(safe_row) - len(safe_row.lstrip())] + indentation = safe_row[: len(safe_row) - len(safe_row.lstrip())] remaining = safe_row.lstrip().rstrip() prefix = f" > {indentation}" continuation_prefix = f" {indentation}" @@ -108,10 +107,7 @@ def render_section( row_prefix = prefix if presentation_index == 0 else continuation_prefix first_row = row_prefix + wrapped[0] if use_color and presentation_index == 0: - first_row = ( - f"{prefix[:-2]}{_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET} " - f"{indentation}{wrapped[0]}" - ) + first_row = f"{prefix[:-2]}{_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET} {indentation}{wrapped[0]}" rows.append(first_row) rows.extend(f"{continuation_prefix}{part}" for part in wrapped[1:]) console.write("\n".join(rows) + "\n") @@ -119,12 +115,12 @@ def render_section( def render_alert( - console: TextIO, - title: str, - lines: Iterable[str], - *, - message_lines: MessageLines = _plain_message_lines, - width: ConsoleWidth = console_width, + console: TextIO, + title: str, + lines: Iterable[str], + *, + message_lines: MessageLines = _plain_message_lines, + width: ConsoleWidth = console_width, ) -> None: """Render a severity-marked, bordered alert with wrapped actionable details.""" ( @@ -155,12 +151,9 @@ def render_alert( break_long_words=True, break_on_hyphens=False, ) or [""] - rows.append( - f"{vertical}{prefix}{wrapped[0]:<{inner_width - len(prefix)}}{vertical}" - ) + rows.append(f"{vertical}{prefix}{wrapped[0]:<{inner_width - len(prefix)}}{vertical}") rows.extend( - f"{vertical}{continuation_prefix}{row:<{inner_width - len(continuation_prefix)}}{vertical}" - for row in wrapped[1:] + f"{vertical}{continuation_prefix}{row:<{inner_width - len(continuation_prefix)}}{vertical}" for row in wrapped[1:] ) rows.append(f"{bottom_left}{horizontal * inner_width}{bottom_right}") console.write("\n".join(rows) + "\n") diff --git a/logicytics/module/redaction.py b/logicytics/module/redaction.py index 819a0fbd..abcd0a49 100644 --- a/logicytics/module/redaction.py +++ b/logicytics/module/redaction.py @@ -77,7 +77,4 @@ def _redact_value(value: Any) -> Any: def redact_mapping(values: Mapping[str, Any]) -> dict[str, Any]: """Return a recursively sanitized copy without changing worker-owned settings.""" - return { - key: REDACTED if _is_secret_key(key) else _redact_value(value) - for key, value in values.items() - } + return {key: REDACTED if _is_secret_key(key) else _redact_value(value) for key, value in values.items()} diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index bf3fddf8..746788a5 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -16,13 +16,14 @@ import sys import traceback import zipfile +from collections.abc import Callable, Mapping from dataclasses import asdict, dataclass from multiprocessing.process import BaseProcess from multiprocessing.queues import Queue from pathlib import Path from subprocess import TimeoutExpired from time import monotonic, sleep -from typing import Any, Mapping, TypedDict, cast, Callable, Protocol, TypeVar +from typing import Any, Protocol, TypedDict, TypeVar, cast from uuid import uuid4 from logicytics.contracts import ( @@ -45,7 +46,7 @@ from logicytics.module.discovery import CollectorCandidate from logicytics.module.errors import CapabilityPolicyError, LogicyticsError from logicytics.module.logging import FileEventLogger, get_application_logger, get_event_logger -from logicytics.module.manifest import CollectorRecord, RunManifest, write_manifest, utc_now +from logicytics.module.manifest import CollectorRecord, RunManifest, utc_now, write_manifest from logicytics.module.output_contracts import core_output_contract from logicytics.module.output_layout import ensure_output_layout from logicytics.module.packaging import package_manifest @@ -68,21 +69,21 @@ class _ProcessContext(Protocol): """Typed multiprocessing context subset used by the supervisor.""" def Process( - self, - group: None = None, - target: Callable[..., object] | None = None, - name: str | None = None, - args: tuple[Any, ...] = (), - kwargs: dict[str, Any] | None = None, - *, - daemon: bool | None = None, + self, + group: None = None, + target: Callable[..., object] | None = None, + name: str | None = None, + args: tuple[Any, ...] = (), + kwargs: dict[str, Any] | None = None, + *, + daemon: bool | None = None, ) -> BaseProcess: """Create a worker process using the configured multiprocessing context.""" ... def Queue( - self, - maxsize: int = 0, + self, + maxsize: int = 0, ) -> Queue[_T]: """Create a typed result queue for worker-to-supervisor messages.""" ... @@ -125,6 +126,7 @@ class _WorkerMessage(TypedDict): @dataclass(slots=True) class _ActiveWorker: """Track process, resource, workspace, and progress state for one active collector.""" + candidate_id: str process: BaseProcess started_at: float @@ -151,11 +153,29 @@ def _require_metadata(candidate: CollectorCandidate) -> CollectorMetadata: class _WorkerMutationGuard: """Constrain audited collector mutations to its private workspace and evidence store.""" - _PATH_EVENTS = {"os.mkdir", "os.remove", "os.rmdir", "os.chmod", "os.chown", "os.utime", "os.truncate"} + _PATH_EVENTS = { + "os.mkdir", + "os.remove", + "os.rmdir", + "os.chmod", + "os.chown", + "os.utime", + "os.truncate", + } _DOUBLE_PATH_EVENTS = {"os.rename", "os.link", "os.symlink"} _BLOCKED_EVENTS = {"os.chdir", "os.putenv", "os.unsetenv", "os.system"} _FORBIDDEN_COMMANDS = { - "choco", "git", "npm", "pip", "pip3", "pnpm", "poetry", "shutdown", "uv", "winget", "yarn", + "choco", + "git", + "npm", + "pip", + "pip3", + "pnpm", + "poetry", + "shutdown", + "uv", + "winget", + "yarn", } _FORBIDDEN_TOOL_REFERENCE = re.compile( r"(? None: """Install audit boundaries for one collector's filesystem and capability scope.""" - self.roots = (workspace.resolve(), (artifact_root / collector_id.replace(".", "_")).resolve()) + self.roots = ( + workspace.resolve(), + (artifact_root / collector_id.replace(".", "_")).resolve(), + ) self.runtime_roots = (Path(sys.base_prefix).resolve(), Path(__file__).resolve().parent) self.collector_source = collector_source.resolve() source_tree = next( (parent for parent in self.collector_source.parents if parent.name.casefold() in {"core", "plugins"}), None, ) - self.collector_roots = () if source_tree is None else ( - (source_tree.parent / "core").resolve(), - (source_tree.parent / "plugins").resolve(), + self.collector_roots = ( + () + if source_tree is None + else ( + (source_tree.parent / "core").resolve(), + (source_tree.parent / "plugins").resolve(), + ) ) self.capabilities = frozenset(capabilities) self.blocked_capabilities = frozenset(blocked_capabilities) @@ -217,9 +244,7 @@ def _check_path(self, value: object) -> None: if not isinstance(value, (str, bytes, os.PathLike)): raise PermissionError("collector filesystem mutation has an unsupported target") path = Path(os.fsdecode(value)).resolve() - if Capability.FILESYSTEM_WRITE in self.capabilities and ( - Capability.FILESYSTEM_WRITE not in self.blocked_capabilities - ): + if Capability.FILESYSTEM_WRITE in self.capabilities and (Capability.FILESYSTEM_WRITE not in self.blocked_capabilities): return if not any(path == root or root in path.parents for root in self.roots): self._require_capability( @@ -234,9 +259,7 @@ def _check_read(self, value: object) -> None: if isinstance(value, int) or not isinstance(value, (str, bytes, os.PathLike)): return path = Path(os.fsdecode(value)).resolve() - if path == self.collector_source or any( - path == root or root in path.parents for root in (*self.roots, *self.runtime_roots) - ): + if path == self.collector_source or any(path == root or root in path.parents for root in (*self.roots, *self.runtime_roots)): return self._require_capability( Capability.FILESYSTEM_READ, @@ -245,12 +268,15 @@ def _check_read(self, value: object) -> None: ) components = tuple(part.casefold() for part in path.parts) browser_data = any(part in {"chrome", "edge", "firefox", "opera software", "opera gx"} for part in components) - private_key = ".ssh" in components or path.name.casefold() in { - "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa" - } or path.suffix.casefold() in {".pem", ".ppk"} - sensitive = private_key or browser_data or any( - label in path.name.casefold() for label in - ("cookie", "credential", "password", "token", "secret", "login data") + private_key = ( + ".ssh" in components + or path.name.casefold() in {"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"} + or path.suffix.casefold() in {".pem", ".ppk"} + ) + sensitive = ( + private_key + or browser_data + or any(label in path.name.casefold() for label in ("cookie", "credential", "password", "token", "secret", "login data")) ) if browser_data: self._require_capability(Capability.BROWSER_DATA, "browser_data", f"target={path}") @@ -266,11 +292,7 @@ def _subprocess_tokens(arguments: tuple[object, ...]) -> tuple[str, ...]: command = arguments[1] if len(arguments) > 1 else None if isinstance(command, (list, tuple)): - tokens = tuple( - os.fsdecode(item) - for item in command - if isinstance(item, (str, bytes, os.PathLike)) - ) + tokens = tuple(os.fsdecode(item) for item in command if isinstance(item, (str, bytes, os.PathLike))) elif isinstance(command, bytes): decoded = os.fsdecode(command) try: @@ -297,7 +319,7 @@ def _command_stem(value: str) -> str: name = Path(value.strip("\"'")).name.casefold() for suffix in (".exe", ".com", ".cmd", ".bat"): if name.endswith(suffix): - return name[:-len(suffix)] + return name[: -len(suffix)] return name def _prohibited_subprocess(self, arguments: tuple[object, ...]) -> str | None: @@ -318,22 +340,21 @@ def _prohibited_subprocess(self, arguments: tuple[object, ...]) -> str | None: return "main_application" command_text = " ".join(normalized) if re.search( - r"(? None: if prohibited := self._prohibited_subprocess(arguments): raise PermissionError(f"collector subprocess command is prohibited: {prohibited}") if event.startswith("socket.") and event in { - "socket.__new__", "socket.bind", "socket.connect", "socket.sendto", "socket.getaddrinfo" + "socket.__new__", + "socket.bind", + "socket.connect", + "socket.sendto", + "socket.getaddrinfo", }: self._require_capability(Capability.NETWORK, "network") socket_type = arguments[2] if len(arguments) > 2 else None - if ( - event == "socket.__new__" - and isinstance(socket_type, int) - and socket_type == int(socket.SOCK_RAW) - ): + if event == "socket.__new__" and isinstance(socket_type, int) and socket_type == int(socket.SOCK_RAW): self._require_capability(Capability.PACKET_CAPTURE, "packet_capture") if event.startswith("winreg."): self._require_capability(Capability.REGISTRY_READ, "registry_read") @@ -373,9 +394,7 @@ def _check_event(self, event: str, arguments: tuple[object, ...]) -> None: mode = arguments[1] if len(arguments) > 1 else None flags = arguments[2] if len(arguments) > 2 else 0 writing = isinstance(mode, str) and any(flag in mode for flag in "wax+") - writing = writing or isinstance(flags, int) and bool( - flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND) - ) + writing = writing or isinstance(flags, int) and bool(flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND)) if writing: self._check_path(arguments[0]) else: @@ -417,12 +436,12 @@ def _result_from_dict(data: _SerializedResult) -> CollectorResult: def _mod_command( - script: Path, - execution_type: str, - workspace: Path | None = None, - collector_id: str = "mod.legacy", - capabilities: tuple[Capability, ...] = (), - blocked_capabilities: tuple[Capability, ...] = (), + script: Path, + execution_type: str, + workspace: Path | None = None, + collector_id: str = "mod.legacy", + capabilities: tuple[Capability, ...] = (), + blocked_capabilities: tuple[Capability, ...] = (), ) -> list[str]: """Build a shell-free command for one copied legacy MODS script.""" if execution_type != "mod_python": @@ -521,10 +540,15 @@ def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage] for level, message in parse_level_messages(completed.stdout): logger.event(level.casefold(), message) excluded_roots = {source_directory.resolve(), (workspace / "tmp").resolve()} - excluded_files = {stdout_path.resolve(), stderr_path.resolve(), (workspace / "events.jsonl").resolve()} + excluded_files = { + stdout_path.resolve(), + stderr_path.resolve(), + (workspace / "events.jsonl").resolve(), + } candidates = [stdout_path, stderr_path] candidates.extend( - path for path in sorted(workspace.rglob("*")) + path + for path in sorted(workspace.rglob("*")) if path.is_file() and path.resolve() not in excluded_files and not any(path.resolve().is_relative_to(root) for root in excluded_roots) @@ -544,7 +568,10 @@ def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage] else: result = CollectorResult.failed( "legacy mod exited unsuccessfully", - errors=(f"exit code {completed.returncode}", completed.stderr.strip() or "no stderr"), + errors=( + f"exit code {completed.returncode}", + completed.stderr.strip() or "no stderr", + ), artifacts=tuple(artifacts), ) except CapabilityPolicyError as error: @@ -583,18 +610,25 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) try: _run_mod_worker(payload, result_queue) except BaseException as error: - result_queue.put({ - "collector_id": payload["collector_id"], - "result": _serialize_result(CollectorResult.failed( - "legacy mod worker crashed", - errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), - )), - }) + result_queue.put( + { + "collector_id": payload["collector_id"], + "result": _serialize_result( + CollectorResult.failed( + "legacy mod worker crashed", + errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), + ) + ), + } + ) return stdout_path = workspace / "stdout.log" stderr_path = workspace / "stderr.log" try: - with stdout_path.open("w", encoding="utf-8") as stdout, stderr_path.open("w", encoding="utf-8") as stderr: + with ( + stdout_path.open("w", encoding="utf-8") as stdout, + stderr_path.open("w", encoding="utf-8") as stderr, + ): with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): collector = _load_collector(Path(payload["path"]), payload["expected_class"]) metadata = collector.metadata() @@ -683,10 +717,7 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) } ) if undeclared_media_types: - raise TypeError( - "collector registered undeclared output media types: " - f"{', '.join(undeclared_media_types)}" - ) + raise TypeError(f"collector registered undeclared output media types: {', '.join(undeclared_media_types)}") result = collector.finalize(context, result) if not isinstance(result, CollectorResult): raise TypeError("finalize() must return CollectorResult") @@ -752,15 +783,10 @@ def _serialize_result(result: CollectorResult) -> _SerializedResult: return { "status": result.status.value, "summary": result.summary, - "artifacts": [ - dict(cast(Mapping[str, object], artifact.to_dict())) - for artifact in result.artifacts - ], + "artifacts": [dict(cast(Mapping[str, object], artifact.to_dict())) for artifact in result.artifacts], "errors": list(result.errors), "metrics": { - key: value - for key, value in result.metrics.items() - if isinstance(value, (int, float, str)) and not isinstance(value, bool) + key: value for key, value in result.metrics.items() if isinstance(value, (int, float, str)) and not isinstance(value, bool) }, } @@ -780,21 +806,13 @@ def run(self, plan: RunPlan) -> RunOutcome: request_blocks = set(plan.request.blocked_capabilities) blocked = configured_blocks.union(request_blocks) policy_violations = { - metadata.id: sorted( - capability.value - for capability in set(metadata.capabilities).intersection(blocked) - ) + metadata.id: sorted(capability.value for capability in set(metadata.capabilities).intersection(blocked)) for metadata in (_require_metadata(candidate) for candidate in plan.collectors) } - policy_violations = { - collector_id: capabilities - for collector_id, capabilities in policy_violations.items() - if capabilities - } + policy_violations = {collector_id: capabilities for collector_id, capabilities in policy_violations.items() if capabilities} if policy_violations: details = "; ".join( - f"{collector_id}={', '.join(capabilities)}" - for collector_id, capabilities in sorted(policy_violations.items()) + f"{collector_id}={', '.join(capabilities)}" for collector_id, capabilities in sorted(policy_violations.items()) ) raise CapabilityPolicyError( "CAPABILITY_BLOCKED", @@ -805,13 +823,7 @@ def run(self, plan: RunPlan) -> RunOutcome: if plan.collectors and not plan.request.acknowledge_authorization: selected_metadata = tuple(_require_metadata(candidate) for candidate in plan.collectors) categories = sorted(str(metadata.specialty) for metadata in selected_metadata) - sensitive_outputs = sorted( - { - category - for metadata in selected_metadata - for category in metadata.sensitive_data_categories - } - ) + sensitive_outputs = sorted({category for metadata in selected_metadata for category in metadata.sensitive_data_categories}) raise PermissionError( "collection requires --acknowledge-authorization " "(acknowledge_authorization=True); " @@ -894,10 +906,7 @@ def run(self, plan: RunPlan) -> RunOutcome: manifest.finalize_status() if plan.request.performance_check: self._write_performance_report(run_directory, manifest) - should_package = ( - self.configuration.runtime.package_completed_runs - and plan.request.output_policy is OutputPolicy.PACKAGE - ) + should_package = self.configuration.runtime.package_completed_runs and plan.request.output_policy is OutputPolicy.PACKAGE if should_package: run_logger.event("info", "run_packaging_started") application_logger.event( @@ -934,9 +943,7 @@ def run(self, plan: RunPlan) -> RunOutcome: ) else: packaging_skip_reason = ( - "manifest_only_output" - if plan.request.output_policy is OutputPolicy.MANIFEST_ONLY - else "configuration_disabled" + "manifest_only_output" if plan.request.output_policy is OutputPolicy.MANIFEST_ONLY else "configuration_disabled" ) run_logger.event( "info", @@ -987,16 +994,16 @@ def run(self, plan: RunPlan) -> RunOutcome: @staticmethod def _execute_post_run_action( - action: PostRunAction, - manifest: RunManifest, - run_logger: FileEventLogger, + action: PostRunAction, + manifest: RunManifest, + run_logger: FileEventLogger, ) -> None: """Schedule an explicit Windows power action only after verified packaging.""" package = manifest.package or {} if ( - manifest.status is not RunStatus.SUCCEEDED - or not isinstance(package.get("path"), str) - or not isinstance(package.get("sha256"), str) + manifest.status is not RunStatus.SUCCEEDED + or not isinstance(package.get("path"), str) + or not isinstance(package.get("sha256"), str) ): raise LogicyticsError("post-run action requires a successful run and verified package") flag = "/r" if action is PostRunAction.REBOOT else "/s" @@ -1010,9 +1017,8 @@ def _execute_post_run_action( except OSError as error: raise LogicyticsError(f"unable to schedule {action.value}: {error}") from error if completed.returncode != 0: - raise LogicyticsError( - f"unable to schedule {action.value}: {completed.stderr.strip() or completed.stdout.strip()}" - ) + error_detail = completed.stderr.strip() or completed.stdout.strip() + raise LogicyticsError(f"unable to schedule {action.value}: {error_detail}") run_logger.event("warning", "post_run_action_scheduled", action=action.value, delay_seconds=60) @staticmethod @@ -1035,24 +1041,21 @@ def _write_performance_report(run_directory: Path, manifest: RunManifest) -> Non performance_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") def _supervise( - self, - plan: RunPlan, - run_id: str, - workspace_root: Path, - artifact_root: Path, - cancellation_file: Path, - manifest: RunManifest, - manifest_path: Path, - records: dict[str, CollectorRecord], - run_logger: FileEventLogger, - application_logger: EventLogger, + self, + plan: RunPlan, + run_id: str, + workspace_root: Path, + artifact_root: Path, + cancellation_file: Path, + manifest: RunManifest, + manifest_path: Path, + records: dict[str, CollectorRecord], + run_logger: FileEventLogger, + application_logger: EventLogger, ) -> None: """Schedule bounded isolated workers and contain each terminal failure.""" pending = list(plan.collectors) - candidates = { - _require_metadata(candidate).id: candidate - for candidate in plan.collectors - } + candidates = {_require_metadata(candidate).id: candidate for candidate in plan.collectors} active: dict[str, _ActiveWorker] = {} retry_not_before: dict[str, float] = {} self._active_workers = active @@ -1070,9 +1073,7 @@ def _supervise( self._cancel_active(active, records, manifest, manifest_path, "run cancellation requested") for candidate in pending: metadata = _require_metadata(candidate) - records[metadata.id].apply_result( - CollectorResult(CollectorStatus.CANCELLED, "not started because run was cancelled") - ) + records[metadata.id].apply_result(CollectorResult(CollectorStatus.CANCELLED, "not started because run was cancelled")) return while pending and len(active) < worker_limit: if any(not worker.parallel_safe for worker in active.values()): @@ -1081,10 +1082,7 @@ def _supervise( metadata = _require_metadata(candidate) if monotonic() < retry_not_before.get(metadata.id, 0): break - dependency_states = { - dependency: records[dependency].status - for dependency in metadata.dependencies - } + dependency_states = {dependency: records[dependency].status for dependency in metadata.dependencies} failed_dependencies = { dependency: status for dependency, status in dependency_states.items() @@ -1092,10 +1090,7 @@ def _supervise( } if failed_dependencies: pending.pop(0) - details = ", ".join( - f"{dependency}={status}" - for dependency, status in sorted(failed_dependencies.items()) - ) + details = ", ".join(f"{dependency}={status}" for dependency, status in sorted(failed_dependencies.items())) records[metadata.id].apply_result( CollectorResult( CollectorStatus.SKIPPED, @@ -1120,23 +1115,14 @@ def _supervise( if active: candidate_class = metadata.resource_class - has_interactive_worker = any( - worker.resource_class is ResourceClass.INTERACTIVE - for worker in active.values() - ) + has_interactive_worker = any(worker.resource_class is ResourceClass.INTERACTIVE for worker in active.values()) - has_same_resource_class = any( - worker.resource_class is candidate_class - for worker in active.values() - ) + has_same_resource_class = any(worker.resource_class is candidate_class for worker in active.values()) conflicts_with_active_worker = ( - candidate_class is ResourceClass.INTERACTIVE - or has_interactive_worker - or ( - candidate_class is not ResourceClass.GENERAL - and has_same_resource_class - ) + candidate_class is ResourceClass.INTERACTIVE + or has_interactive_worker + or (candidate_class is not ResourceClass.GENERAL and has_same_resource_class) ) if conflicts_with_active_worker: @@ -1144,9 +1130,7 @@ def _supervise( reserved_output_bytes = sum(worker.reserved_output_bytes for worker in active.values()) remaining_output_bytes = ( - self.configuration.runtime.maximum_run_output_bytes - - committed_output_bytes - - reserved_output_bytes + self.configuration.runtime.maximum_run_output_bytes - committed_output_bytes - reserved_output_bytes ) if remaining_output_bytes < 1: if active: @@ -1215,9 +1199,7 @@ def _supervise( "output_budget_bytes": output_budget, "resource_class": metadata.resource_class.value, "parallel_safe": metadata.parallel_safe, - "declared_capabilities": ",".join( - sorted(capability.value for capability in metadata.capabilities) - ) or "none", + "declared_capabilities": ",".join(sorted(capability.value for capability in metadata.capabilities)) or "none", } run_logger.event("info", "collector_started", **start_fields) application_logger.event( @@ -1246,11 +1228,7 @@ def _supervise( committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) capability_error = next( - ( - error - for error in record.errors - if error.startswith(("CAPABILITY_BLOCKED:", "CAPABILITY_DECLARATION_MISMATCH:")) - ), + (error for error in record.errors if error.startswith(("CAPABILITY_BLOCKED:", "CAPABILITY_DECLARATION_MISMATCH:"))), None, ) if capability_error is not None: @@ -1266,13 +1244,13 @@ def _supervise( attempt_status = record.status attempt_duration = record.duration_seconds or 0.0 retry_scheduled = self._schedule_retry( - candidates[collector_id], - records[collector_id], - artifact_bytes, - pending, - retry_not_before, - cancellation_file, - run_logger, + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, ) finish_level = "warning" if retry_scheduled else "info" finish_fields = { @@ -1318,10 +1296,7 @@ def _supervise( CollectorResult( CollectorStatus.FAILED, "collector exceeded its declared memory limit", - errors=( - f"collector working set {memory_bytes} exceeded " - f"maximum_memory_bytes={worker.maximum_memory_bytes}", - ), + errors=(f"collector working set {memory_bytes} exceeded maximum_memory_bytes={worker.maximum_memory_bytes}",), ), worker, ) @@ -1329,13 +1304,13 @@ def _supervise( committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) if not self._schedule_retry( - candidates[collector_id], - records[collector_id], - artifact_bytes, - pending, - retry_not_before, - cancellation_file, - run_logger, + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, ): run_logger.event("error", "collector_memory_limit_exceeded", collector_id=collector_id) write_manifest(manifest_path, manifest) @@ -1356,13 +1331,13 @@ def _supervise( committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) if not self._schedule_retry( - candidates[collector_id], - records[collector_id], - artifact_bytes, - pending, - retry_not_before, - cancellation_file, - run_logger, + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, ): run_logger.event("error", "collector_timed_out", collector_id=collector_id) write_manifest(manifest_path, manifest) @@ -1384,13 +1359,13 @@ def _supervise( committed_output_bytes += artifact_bytes self._cleanup_worker_temporary_directory(worker) if not self._schedule_retry( - candidates[collector_id], - records[collector_id], - artifact_bytes, - pending, - retry_not_before, - cancellation_file, - run_logger, + candidates[collector_id], + records[collector_id], + artifact_bytes, + pending, + retry_not_before, + cancellation_file, + run_logger, ): run_logger.event("error", "collector_exited_without_result", collector_id=collector_id) write_manifest(manifest_path, manifest) @@ -1406,21 +1381,21 @@ def _worker_memory_bytes(process: BaseProcess) -> int | None: @staticmethod def _schedule_retry( - candidate: CollectorCandidate, - record: CollectorRecord, - artifact_bytes: int, - pending: list[CollectorCandidate], - retry_not_before: dict[str, float], - cancellation_file: Path, - run_logger: FileEventLogger, + candidate: CollectorCandidate, + record: CollectorRecord, + artifact_bytes: int, + pending: list[CollectorCandidate], + retry_not_before: dict[str, float], + cancellation_file: Path, + run_logger: FileEventLogger, ) -> bool: """Retry only explicitly permitted failed attempts that produced no evidence.""" metadata = _require_metadata(candidate) if ( - record.status != CollectorStatus.FAILED.value - or record.attempt_count > metadata.maximum_retries - or artifact_bytes != 0 - or cancellation_file.exists() + record.status != CollectorStatus.FAILED.value + or record.attempt_count > metadata.maximum_retries + or artifact_bytes != 0 + or cancellation_file.exists() ): return False record.retry_history.append( @@ -1576,18 +1551,16 @@ def _apply_worker_result(record: CollectorRecord, result: CollectorResult, worke record.worker_pid = worker.process.pid record.worker_exit_code = worker.process.exitcode if record.termination_reason is None: - record.termination_reason = ( - "completed" if result.status == CollectorStatus.SUCCEEDED else result.status.value - ) + record.termination_reason = "completed" if result.status == CollectorStatus.SUCCEEDED else result.status.value RunSupervisor._refresh_worker_progress(record, worker) def _cancel_active( - self, - active: dict[str, _ActiveWorker], - records: dict[str, CollectorRecord], - manifest: RunManifest, - manifest_path: Path, - reason: str, + self, + active: dict[str, _ActiveWorker], + records: dict[str, CollectorRecord], + manifest: RunManifest, + manifest_path: Path, + reason: str, ) -> None: """Terminate active workers, mark their records cancelled, and persist the manifest.""" for collector_id, worker in tuple(active.items()): @@ -1603,11 +1576,11 @@ def _cancel_active( write_manifest(manifest_path, manifest) def _cancel_records( - self, - records: dict[str, CollectorRecord], - manifest: RunManifest, - manifest_path: Path, - reason: str, + self, + records: dict[str, CollectorRecord], + manifest: RunManifest, + manifest_path: Path, + reason: str, ) -> None: """Cancel active and still-planned records, then write the final cancellation state.""" self._cancel_active(self._active_workers, records, manifest, manifest_path, reason) diff --git a/logicytics/module/sysinternals.py b/logicytics/module/sysinternals.py index 12e67103..aad4aa28 100644 --- a/logicytics/module/sysinternals.py +++ b/logicytics/module/sysinternals.py @@ -29,8 +29,8 @@ def to_dict(self) -> dict[str, str]: def ensure_sysinternals( - project_root: Path, - settings: MaintenanceSettings | None = None, + project_root: Path, + settings: MaintenanceSettings | None = None, ) -> SysinternalsState: """Honor YAML opt-out or securely download, validate, and extract Sysinternals.""" settings = settings or MaintenanceSettings() @@ -43,9 +43,10 @@ def ensure_sysinternals( if not archive.is_file(): archive.parent.mkdir(parents=True, exist_ok=True) try: - with urlopen(settings.sysinternals_download_url, timeout=30) as response, NamedTemporaryFile( - mode="wb", dir=archive.parent, delete=False - ) as temporary: + with ( + urlopen(settings.sysinternals_download_url, timeout=30) as response, + NamedTemporaryFile(mode="wb", dir=archive.parent, delete=False) as temporary, + ): temporary.write(response.read()) downloaded = Path(temporary.name) except (OSError, URLError) as error: diff --git a/logicytics/module/validation_worker.py b/logicytics/module/validation_worker.py index cd707ad8..f7b05517 100644 --- a/logicytics/module/validation_worker.py +++ b/logicytics/module/validation_worker.py @@ -13,13 +13,17 @@ from typing import cast from logicytics.contracts import ( + Artifact, + ArtifactWriter, Collector, CollectorContext, CollectorKind, CollectorMetadata, CoreCollector, + EventLogger, + EvidenceKind, PluginCollector, - ValidationResult, EventLogger, Artifact, EvidenceKind, ArtifactWriter, + ValidationResult, ) from logicytics.module.logging import ApplicationLogger @@ -27,7 +31,7 @@ class _ProbeLogger(EventLogger): """Discard structured events emitted during the side-effect-free validation probe.""" - def event(self, level: str, message: str, **fields: int | float | str) -> None: + def event(self, level: str, message: str, **fields: float | str) -> None: """Accept probe events without exposing them through the JSON-only worker output.""" @@ -35,12 +39,12 @@ class _ProbeArtifactWriter(ArtifactWriter): """Reject evidence registration during preflight validation.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> Artifact: """Prevent a validation method from registering collection artifacts.""" raise RuntimeError("validate() must not register artifacts") @@ -76,7 +80,7 @@ def __init__(self) -> None: """Initialize an inactive audit guard for the validation context.""" self.active = False - def __enter__(self) -> "_ValidationSideEffectGuard": + def __enter__(self) -> _ValidationSideEffectGuard: """Install the audit hook and begin rejecting validation side effects.""" sys.addaudithook(self._reject_side_effect) self.active = True @@ -94,9 +98,7 @@ def _reject_side_effect(self, event: str, arguments: tuple[object, ...]) -> None mode = arguments[1] if len(arguments) > 1 else None flags = arguments[2] if len(arguments) > 2 else 0 writing = isinstance(mode, str) and any(flag in mode for flag in "wax+") - writing = writing or isinstance(flags, int) and bool( - flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND) - ) + writing = writing or isinstance(flags, int) and bool(flags & (os.O_WRONLY | os.O_RDWR | os.O_CREAT | os.O_TRUNC | os.O_APPEND)) if not writing: return elif event not in self._BLOCKED_EVENTS: @@ -116,16 +118,14 @@ def _load_module(path: Path): def _validate_contract( - collector_type: type[Collector], - kind: CollectorKind, + collector_type: type[Collector], + kind: CollectorKind, ) -> CollectorMetadata: """Validate collector signatures, metadata consistency, and probe purity.""" required_base = CoreCollector if kind is CollectorKind.CORE else PluginCollector if not issubclass(collector_type, required_base): - raise ValueError( - f"collector must inherit from {required_base.__name__}" - ) + raise ValueError(f"collector must inherit from {required_base.__name__}") for method_name, expected_parameters in { "metadata": 0, @@ -143,18 +143,12 @@ def _validate_contract( parameters = list(inspect.signature(method).parameters.values()) if method_name == "metadata": - parameters = ( - parameters[1:] - if parameters and parameters[0].name == "cls" - else parameters - ) + parameters = parameters[1:] if parameters and parameters[0].name == "cls" else parameters elif parameters and parameters[0].name == "self": parameters = parameters[1:] if len(parameters) != expected_parameters: - raise ValueError( - f"{method_name} has an invalid signature" - ) + raise ValueError(f"{method_name} has an invalid signature") raw_dependencies = getattr(collector_type, "dependencies", None) @@ -171,34 +165,20 @@ def _validate_contract( declared_dependencies = dependencies() - if ( - not isinstance(declared_dependencies, tuple) - or not all( - isinstance(item, str) - for item in declared_dependencies - ) - ): - raise ValueError( - "dependencies() must return tuple[str, ...]" - ) + if not isinstance(declared_dependencies, tuple) or not all(isinstance(item, str) for item in declared_dependencies): + raise ValueError("dependencies() must return tuple[str, ...]") metadata = collector_type.metadata() if not isinstance(metadata, CollectorMetadata): - raise ValueError( - "metadata() must return CollectorMetadata" - ) + raise ValueError("metadata() must return CollectorMetadata") if declared_dependencies != metadata.dependencies: - raise ValueError( - "dependencies() must match metadata.dependencies" - ) + raise ValueError("dependencies() must match metadata.dependencies") collector = collector_type() - with tempfile.TemporaryDirectory( - prefix="logicytics-validation-" - ) as temporary: + with tempfile.TemporaryDirectory(prefix="logicytics-validation-") as temporary: workspace = Path(temporary) temporary_directory = workspace / "tmp" temporary_directory.mkdir() @@ -218,14 +198,10 @@ def _validate_contract( with _ValidationSideEffectGuard(): validation = collector.validate(context) except Exception as error: - raise ValueError( - f"validate() probe failed: {error}" - ) from error + raise ValueError(f"validate() probe failed: {error}") from error if not isinstance(validation, ValidationResult): - raise ValueError( - "validate() must return ValidationResult" - ) + raise ValueError("validate() must return ValidationResult") return metadata diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index cfce57d0..1916bfd0 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -12,7 +12,7 @@ import sys import tempfile from collections.abc import Sequence -from datetime import datetime, timedelta, timezone +from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any @@ -34,13 +34,10 @@ class ProcessAdapter: @staticmethod def _command( - command: Sequence[str | os.PathLike[str]], + command: Sequence[str | os.PathLike[str]], ) -> tuple[str, ...]: """Validate and normalize an explicit shell-free command sequence.""" - normalized = tuple( - argument if isinstance(argument, str) else argument.__fspath__() - for argument in command - ) + normalized = tuple(argument if isinstance(argument, str) else argument.__fspath__() for argument in command) if not normalized: raise ValueError("command must contain at least one argument") @@ -49,8 +46,8 @@ def _command( @staticmethod def run( - command: Sequence[str | os.PathLike[str]], - **options: Any, + command: Sequence[str | os.PathLike[str]], + **options: Any, ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: """Delegate to the guarded stdlib runner while retaining its familiar result contract.""" normalized = ProcessAdapter._command(command) @@ -61,10 +58,7 @@ def run( timeout = options.get("timeout") - if timeout is not None and ( - not isinstance(timeout, (int, float)) - or timeout <= 0 - ): + if timeout is not None and (not isinstance(timeout, (int, float)) or timeout <= 0): raise ValueError("command timeout must be positive") capture_output = options.pop("capture_output", False) @@ -73,14 +67,9 @@ def run( return subprocess.run(normalized, **options) if "stdout" in options or "stderr" in options: - raise ValueError( - "capture_output cannot be combined with explicit streams" - ) + raise ValueError("capture_output cannot be combined with explicit streams") - wants_text = bool( - options.pop("text", False) - or options.get("encoding") is not None - ) + wants_text = bool(options.pop("text", False) or options.get("encoding") is not None) encoding = options.pop("encoding", None) or "utf-8" errors = options.pop("errors", None) or "replace" @@ -88,9 +77,7 @@ def run( capture_directory = Path(capture_directory) if not capture_directory.is_dir(): - raise ValueError( - "capture_directory must be an existing directory" - ) + raise ValueError("capture_directory must be an existing directory") with ( tempfile.TemporaryFile(dir=capture_directory) as stdout, @@ -106,14 +93,8 @@ def run( stdout_size = stdout.tell() stderr_size = stderr.tell() - if ( - stdout_size > ProcessAdapter.maximum_capture_bytes - or stderr_size > ProcessAdapter.maximum_capture_bytes - ): - raise ValueError( - f"command output exceeds the " - f"{ProcessAdapter.maximum_capture_bytes}-byte capture limit" - ) + if stdout_size > ProcessAdapter.maximum_capture_bytes or stderr_size > ProcessAdapter.maximum_capture_bytes: + raise ValueError(f"command output exceeds the {ProcessAdapter.maximum_capture_bytes}-byte capture limit") stdout.seek(0) stderr.seek(0) @@ -137,9 +118,9 @@ def run( ) def popen( - self, - command: Sequence[str | os.PathLike[str]], - **options: Any, + self, + command: Sequence[str | os.PathLike[str]], + **options: Any, ) -> subprocess.Popen[Any]: """Start one explicit long-lived process without invoking a command shell.""" normalized = self._command(command) @@ -152,11 +133,7 @@ def popen( @staticmethod def memory_bytes(process_id: int) -> int | None: """Return one process's resident memory through the host-specific boundary.""" - if ( - not isinstance(process_id, int) - or isinstance(process_id, bool) - or process_id <= 0 - ): + if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: raise ValueError("process_id must be a positive integer") if os.name == "nt": @@ -176,11 +153,7 @@ def memory_bytes(process_id: int) -> int | None: @staticmethod def terminate_process_group(process_id: int) -> None: """Request termination of one non-Windows process group.""" - if ( - not isinstance(process_id, int) - or isinstance(process_id, bool) - or process_id <= 0 - ): + if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: raise ValueError("process_id must be a positive integer") os.killpg(process_id, signal.SIGTERM) @@ -241,8 +214,7 @@ def last_write_time(key: Any) -> str | None: if result != 0: return None ticks = (timestamp.dwHighDateTime << 32) | timestamp.dwLowDateTime - return (datetime(1601, 1, 1, tzinfo=timezone.utc) - + timedelta(microseconds=ticks // 10)).isoformat() + return (datetime(1601, 1, 1, tzinfo=UTC) + timedelta(microseconds=ticks // 10)).isoformat() registry_adapter = RegistryAdapter() @@ -297,14 +269,8 @@ class WindowsApiAdapter: @staticmethod def load_library(name: str): """Load one validated Win32 DLL name when running on Windows.""" - if ( - not isinstance(name, str) - or not name - or any(character in name for character in "/\\\x00") - ): - raise ValueError( - "Win32 library name must be a simple non-empty name" - ) + if not isinstance(name, str) or not name or any(character in name for character in "/\\\x00"): + raise ValueError("Win32 library name must be a simple non-empty name") if sys.platform != "win32": raise OSError("Win32 libraries are unavailable on this platform") @@ -323,6 +289,7 @@ def process_working_set(self, process_id: int) -> int | None: class ProcessMemoryCounters(ctypes.Structure): """Minimal PROCESS_MEMORY_COUNTERS layout used by GetProcessMemoryInfo.""" + _fields_ = [ ("cb", ctypes.c_ulong), ("PageFaultCount", ctypes.c_ulong), @@ -358,6 +325,7 @@ def process_descendants(self, parent_process_id: int) -> tuple[int, ...]: class ProcessEntry(ctypes.Structure): """Minimal PROCESSENTRY32W layout used for descendant enumeration.""" + _fields_ = [ ("dwSize", ctypes.c_ulong), ("cntUsage", ctypes.c_ulong), @@ -384,9 +352,7 @@ class ProcessEntry(ctypes.Structure): relationships: dict[int, list[int]] = {} valid = kernel.Process32FirstW(snapshot, ctypes.byref(entry)) while valid: - relationships.setdefault(int(entry.th32ParentProcessID), []).append( - int(entry.th32ProcessID) - ) + relationships.setdefault(int(entry.th32ParentProcessID), []).append(int(entry.th32ProcessID)) valid = kernel.Process32NextW(snapshot, ctypes.byref(entry)) descendants: list[int] = [] pending = [parent_process_id] diff --git a/logicytics/virtual_environment.py b/logicytics/virtual_environment.py index 1dd30cd8..201a4d45 100644 --- a/logicytics/virtual_environment.py +++ b/logicytics/virtual_environment.py @@ -8,7 +8,6 @@ from logicytics.module.presentation import render_alert - _LOCAL_ENVIRONMENT = Path(".venv") _ACTIVATION_SCRIPT = Path("Scripts") / "Activate.ps1" diff --git a/pyproject.toml b/pyproject.toml index 3e3678af..021eeae4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,5 +12,8 @@ logicytics = "logicytics.cli:main" testpaths = ["tests"] [tool.ruff] -line-length = 100 +line-length = 140 target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W"] diff --git a/tests/fixtures/collectors.py b/tests/fixtures/collectors.py index 61bac4d1..33a0baa9 100644 --- a/tests/fixtures/collectors.py +++ b/tests/fixtures/collectors.py @@ -56,12 +56,12 @@ def plugin_collector_source() -> str: return COLLECTOR.replace("CoreCollector", "PluginCollector").replace( ' author="tests",', ' author="tests",\n' - ' privilege_level=PrivilegeLevel.STANDARD,\n' - ' sensitive_data_categories=(),\n' - ' network_access=NetworkAccess.NONE,\n' - ' estimated_cost=EstimatedCost.LOW,\n' - ' timeout_seconds=60,\n' - ' maximum_output_bytes=100 * 1024 * 1024,\n' + " privilege_level=PrivilegeLevel.STANDARD,\n" + " sensitive_data_categories=(),\n" + " network_access=NetworkAccess.NONE,\n" + " estimated_cost=EstimatedCost.LOW,\n" + " timeout_seconds=60,\n" + " maximum_output_bytes=100 * 1024 * 1024,\n" ' minimum_contract_version="4.0",', ) @@ -94,13 +94,13 @@ def mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, Any] def delayed_collector_source( - filename: str, - delay: float, - *, - parallel_safe: bool = True, - dependencies: tuple[str, ...] = (), - resource_class: ResourceClass = ResourceClass.GENERAL, - fail: bool = False, + filename: str, + delay: float, + *, + parallel_safe: bool = True, + dependencies: tuple[str, ...] = (), + resource_class: ResourceClass = ResourceClass.GENERAL, + fail: bool = False, ) -> str: """Create a valid fixture collector with observable scheduling duration.""" class_name = "".join(part.title() for part in filename.split("_")) diff --git a/tests/fixtures/file_listing.py b/tests/fixtures/file_listing.py index f1073641..102141a5 100644 --- a/tests/fixtures/file_listing.py +++ b/tests/fixtures/file_listing.py @@ -2,22 +2,21 @@ from __future__ import annotations +from collections.abc import Iterable from pathlib import Path -from typing import Iterable def list_files( - root: Path, - *, - extensions: Iterable[str] = (), - excluded_names: Iterable[str] = (), - excluded_directories: Iterable[str] = (), - append_to: Iterable[Path] = (), + root: Path, + *, + extensions: Iterable[str] = (), + excluded_names: Iterable[str] = (), + excluded_directories: Iterable[str] = (), + append_to: Iterable[Path] = (), ) -> tuple[Path, ...]: """Return sorted normalized files under root, applying explicit filters safely.""" resolved_root = root.resolve() - normalized_extensions = {extension.casefold() if extension.startswith(".") else f".{extension.casefold()}" for - extension in extensions} + normalized_extensions = {extension.casefold() if extension.startswith(".") else f".{extension.casefold()}" for extension in extensions} names = {name.casefold() for name in excluded_names} directories = {name.casefold() for name in excluded_directories} results = {Path(path).resolve() for path in append_to} diff --git a/tests/test_artifact_writer.py b/tests/test_artifact_writer.py index a3b15aa4..64ee8fad 100644 --- a/tests/test_artifact_writer.py +++ b/tests/test_artifact_writer.py @@ -7,14 +7,14 @@ from pathlib import Path from unittest.mock import patch -from logicytics.module.artifacts import WorkspaceArtifactWriter -from logicytics.module.configuration import ( - default_config, -) from logicytics.contracts import ( EvidenceKind, RunRequest, ) +from logicytics.module.artifacts import WorkspaceArtifactWriter +from logicytics.module.configuration import ( + default_config, +) from logicytics.module.discovery import preflight from logicytics.module.errors import ArtifactError from logicytics.module.planner import build_plan diff --git a/tests/test_cli.py b/tests/test_cli.py index 5ce16718..493fa058 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,11 +27,15 @@ def test_cli_requires_activating_the_existing_local_environment(self) -> None: activation_script.write_text("", encoding="utf-8") output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=root), patch.object( + with ( + patch("logicytics.cli._project_root", return_value=root), + patch.object( sys, "prefix", sys.base_prefix, - ), patch("sys.stderr", output): + ), + patch("sys.stderr", output), + ): self.assertEqual(2, main(["preflight"])) self.assertIn(r".\.venv\Scripts\Activate.ps1", output.getvalue()) @@ -42,11 +46,15 @@ def test_cli_requires_the_installer_when_the_local_environment_is_missing(self) with tempfile.TemporaryDirectory() as temporary: output = io.StringIO() - with patch("logicytics.cli._project_root", return_value=Path(temporary)), patch.object( + with ( + patch("logicytics.cli._project_root", return_value=Path(temporary)), + patch.object( sys, "prefix", sys.base_prefix, - ), patch("sys.stderr", output): + ), + patch("sys.stderr", output), + ): self.assertEqual(2, main(["preflight"])) rendered = " ".join(output.getvalue().split()) @@ -71,15 +79,17 @@ def test_capabilities_default_to_metadata_and_cli_can_block_them(self) -> None: self.assertEqual((), request.blocked_capabilities) blocked = cli_methods.request( - parser.parse_args([ - "run", - "--profile", - "standard", - "--block-capability", - "network", - "--block-capability", - "filesystem_read", - ]), + parser.parse_args( + [ + "run", + "--profile", + "standard", + "--block-capability", + "network", + "--block-capability", + "filesystem_read", + ] + ), default_workers=4, ) self.assertEqual( @@ -166,13 +176,15 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None with self.assertRaisesRegex(ValueError, "--profile"): cli_methods.request( - parser.parse_args([ - "run", - "--mode", - "quick", - "--profile", - "deep", - ]), + parser.parse_args( + [ + "run", + "--mode", + "quick", + "--profile", + "deep", + ] + ), 4, ) @@ -185,13 +197,16 @@ def test_modes_action_renders_a_summary_and_writes_the_machine_readable_matrix(s root = Path(temporary) output = io.StringIO() - with patch.object( + with ( + patch.object( CLI, "project_root", return_value=root, - ), patch( - "sys.stderr", - output, + ), + patch( + "sys.stderr", + output, + ), ): self.assertEqual(0, main(["--modes"])) @@ -199,20 +214,14 @@ def test_modes_action_renders_a_summary_and_writes_the_machine_readable_matrix(s self.assertIn("Execution modes", rendered) self.assertIn("Machine-readable matrix:", rendered) self.assertNotIn("{", rendered) - payload = json.loads( - (root / "output" / "logs" / "debug" / "modes.json").read_text( - encoding="utf-8" - ) - ) + payload = json.loads((root / "output" / "logs" / "debug" / "modes.json").read_text(encoding="utf-8")) self.assertEqual(1, payload["schema_version"]) self.assertEqual( list(EXECUTION_MODES), [item["name"] for item in payload["modes"]], ) - self.assertTrue( - all("legacy_aliases" in item for item in payload["modes"]) - ) + self.assertTrue(all("legacy_aliases" in item for item in payload["modes"])) self.assertEqual([], payload["collectors"]) def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> None: @@ -230,7 +239,7 @@ def test_cli_without_action_prints_help_and_modes_are_parser_exclusive(self) -> self.assertIn("not allowed with argument", errors.getvalue()) def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( - self, + self, ) -> None: """The paired update options launch exactly one shell-free visible Windows action.""" with tempfile.TemporaryDirectory() as temporary: @@ -245,30 +254,37 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( ) output = io.StringIO() - with patch.object( + with ( + patch.object( CLI, CLI.project_root.__name__, return_value=root, - ), patch.object( - process_adapter, - process_adapter.run.__name__, - return_value=git, - ), patch.object( - CLI, - CLI.launch_action_window.__name__, - return_value=321, - ) as launch, patch( - "sys.stderr", - output, + ), + patch.object( + process_adapter, + process_adapter.run.__name__, + return_value=git, + ), + patch.object( + CLI, + CLI.launch_action_window.__name__, + return_value=321, + ) as launch, + patch( + "sys.stderr", + output, + ), ): self.assertEqual( 0, - main([ - "update", - "--launch-action", - "debug", - "--new-window", - ]), + main( + [ + "update", + "--launch-action", + "debug", + "--new-window", + ] + ), ) rendered = output.getvalue() @@ -276,23 +292,22 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( self.assertIn("Launched action: debug", rendered) self.assertIn("Launched process id: 321", rendered) self.assertNotIn("{", rendered) - payload = json.loads( - (root / "output" / "logs" / "debug" / "update.json").read_text( - encoding="utf-8" - ) - ) + payload = json.loads((root / "output" / "logs" / "debug" / "update.json").read_text(encoding="utf-8")) self.assertEqual("debug", payload["launched_action"]) self.assertEqual(321, payload["launched_process_id"]) launch.assert_called_once_with(root, "debug") error_logger = MagicMock() - with patch.object( + with ( + patch.object( CLI, CLI.project_root.__name__, return_value=root, - ), patch( - "logicytics.cli.commands.get_application_logger", - return_value=error_logger, + ), + patch( + "logicytics.cli.commands.get_application_logger", + return_value=error_logger, + ), ): self.assertEqual( 2, @@ -303,28 +318,27 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( "Command error", ("--new-window and --launch-action must be provided together",), ) - event_messages = [ - call.args[1] - for call in error_logger.event.call_args_list - if len(call.args) > 1 - ] + event_messages = [call.args[1] for call in error_logger.event.call_args_list if len(call.args) > 1] self.assertIn("command_finished", event_messages) def test_new_window_launcher_uses_current_interpreter_without_a_shell( - self, + self, ) -> None: """Visible maintenance windows preserve argument boundaries and repository cwd.""" root = Path("C:/repo").resolve() process = MagicMock(pid=42) - with patch( + with ( + patch( "sys.platform", "win32", - ), patch.object( - process_adapter, - process_adapter.popen.__name__, - return_value=process, - ) as popen: + ), + patch.object( + process_adapter, + process_adapter.popen.__name__, + return_value=process, + ) as popen, + ): self.assertEqual( 42, cli_methods.launch_action_window(root, "preflight"), @@ -338,12 +352,15 @@ def test_new_window_launcher_uses_current_interpreter_without_a_shell( close_fds=True, ) - with patch( + with ( + patch( "sys.platform", "linux", - ), self.assertRaisesRegex( - OSError, - "only on Windows", + ), + self.assertRaisesRegex( + OSError, + "only on Windows", + ), ): cli_methods.launch_action_window(root, "debug") diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index ab0c35d5..d2e8cec4 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -24,8 +24,8 @@ ArtifactWriter, CollectorContext, CollectorStatus, - EvidenceKind, EventLogger, + EvidenceKind, ) @@ -33,35 +33,33 @@ class _RejectingArtifactWriter(ArtifactWriter): """Reject artifact publication during cancellation tests.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> Artifact: - raise AssertionError( - f"cancelled collector unexpectedly registered {source}" - ) + raise AssertionError(f"cancelled collector unexpectedly registered {source}") class _NullEventLogger(EventLogger): """Discard structured progress events emitted during tests.""" def event( - self, - level: str, - message: str, - **fields: int | float | str, + self, + level: str, + message: str, + **fields: float | str, ) -> None: return None def _context( - workspace: Path, - settings: dict[str, object] | None = None, - *, - collector_id: str = "core.test.cancellation", + workspace: Path, + settings: dict[str, object] | None = None, + *, + collector_id: str = "core.test.cancellation", ) -> tuple[CollectorContext, Path]: """Create a real CollectorContext and its cancellation sentinel.""" @@ -114,8 +112,8 @@ def test_sensitive_copy_cancellation_removes_unpublished_bytes(self) -> None: ) def cancel_after_copy( - source: Path, - destination: Path, + source: Path, + destination: Path, ) -> Path: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) @@ -123,14 +121,10 @@ def cancel_after_copy( return destination with patch( - "core.filesystem.sensitive_file_inventory._copy", - side_effect=cancel_after_copy, + "core.filesystem.sensitive_file_inventory._copy", + side_effect=cancel_after_copy, ): - result = ( - sensitive_file_inventory - .SensitiveFileInventoryCollector() - .collect(context) - ) + result = sensitive_file_inventory.SensitiveFileInventoryCollector().collect(context) self.assertEqual( CollectorStatus.CANCELLED, @@ -139,15 +133,11 @@ def cancel_after_copy( self.assertEqual( [], - [ - path - for path in context.workspace.rglob("*") - if path.is_file() and path != cancellation_file - ], + [path for path in context.workspace.rglob("*") if path.is_file() and path != cancellation_file], ) def test_browser_and_media_copy_cancellation_remove_unpublished_bytes( - self, + self, ) -> None: """Browser and media collectors remove incomplete private copies.""" @@ -157,13 +147,7 @@ def test_browser_and_media_copy_cancellation_remove_unpublished_bytes( local = root / "local" roaming = root / "roaming" - profile = ( - local - / "Google" - / "Chrome" - / "User Data" - / "Default" - ) + profile = local / "Google" / "Chrome" / "User Data" / "Default" profile.mkdir(parents=True) (profile / "History").write_bytes(b"history") @@ -174,9 +158,9 @@ def test_browser_and_media_copy_cancellation_remove_unpublished_bytes( ) def cancel_browser_copy( - _source: Path, - destination: Path, - copied_bytes: int, + _source: Path, + destination: Path, + copied_bytes: int, ) -> tuple[Path, int]: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(b"history") @@ -198,11 +182,7 @@ def cancel_browser_copy( side_effect=cancel_browser_copy, ), ): - browser_result = ( - browser_data_backup - .BrowserDataBackupCollector() - .collect(browser_context) - ) + browser_result = browser_data_backup.BrowserDataBackupCollector().collect(browser_context) self.assertEqual( CollectorStatus.CANCELLED, @@ -211,11 +191,7 @@ def cancel_browser_copy( self.assertEqual( [], - [ - path - for path in browser_context.workspace.rglob("*") - if path.is_file() and path != browser_cancellation - ], + [path for path in browser_context.workspace.rglob("*") if path.is_file() and path != browser_cancellation], ) home = root / "home" @@ -230,8 +206,8 @@ def cancel_browser_copy( ) def cancel_media_copy( - source: Path, - destination: Path, + source: Path, + destination: Path, ) -> None: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) @@ -250,11 +226,7 @@ def cancel_media_copy( side_effect=cancel_media_copy, ), ): - media_result = ( - media_backup - .MediaBackupCollector() - .collect(media_context) - ) + media_result = media_backup.MediaBackupCollector().collect(media_context) self.assertEqual( CollectorStatus.CANCELLED, @@ -263,11 +235,7 @@ def cancel_media_copy( self.assertEqual( [], - [ - path - for path in media_context.workspace.rglob("*") - if path.is_file() and path != media_cancellation - ], + [path for path in media_context.workspace.rglob("*") if path.is_file() and path != media_cancellation], ) def test_event_queries_cannot_publish_after_cancellation(self) -> None: @@ -296,7 +264,7 @@ def test_event_queries_cannot_publish_after_cancellation(self) -> None: for collector_type, subprocess_target, collector_id in collectors: with self.subTest( - collector=collector_type.__name__, + collector=collector_type.__name__, ): context, cancellation_file = _context( root / collector_type.__name__, @@ -304,8 +272,8 @@ def test_event_queries_cannot_publish_after_cancellation(self) -> None: ) def finish_after_cancellation( - *_args: Any, - **_kwargs: Any, + *_args: Any, + **_kwargs: Any, ) -> subprocess.CompletedProcess[str]: cancellation_file.touch() @@ -317,8 +285,8 @@ def finish_after_cancellation( ) with patch( - subprocess_target, - side_effect=finish_after_cancellation, + subprocess_target, + side_effect=finish_after_cancellation, ): result = collector_type().collect(context) @@ -329,15 +297,11 @@ def finish_after_cancellation( self.assertEqual( [], - [ - path - for path in context.workspace.rglob("*") - if path.is_file() and path != cancellation_file - ], + [path for path in context.workspace.rglob("*") if path.is_file() and path != cancellation_file], ) def test_system_data_and_ssh_copy_cancellation_remove_partial_backups( - self, + self, ) -> None: """System and SSH collectors delete private incomplete copies.""" @@ -346,13 +310,7 @@ def test_system_data_and_ssh_copy_cancellation_remove_partial_backups( windows = root / "Windows" - event_log = ( - windows - / "System32" - / "winevt" - / "Logs" - / "System.evtx" - ) + event_log = windows / "System32" / "winevt" / "Logs" / "System.evtx" event_log.parent.mkdir(parents=True) event_log.write_bytes(b"event log") @@ -363,8 +321,8 @@ def test_system_data_and_ssh_copy_cancellation_remove_partial_backups( ) def cancel_system_copy( - source: Path, - destination: Path, + source: Path, + destination: Path, ) -> None: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) @@ -380,18 +338,11 @@ def cancel_system_copy( }, ), patch( - ( - "core.system.windows_system_data_backup." - "filesystem_adapter.copy_file" - ), + ("core.system.windows_system_data_backup.filesystem_adapter.copy_file"), side_effect=cancel_system_copy, ), ): - system_result = ( - windows_system_data_backup - .WindowsSystemDataBackupCollector() - .collect(system_context) - ) + system_result = windows_system_data_backup.WindowsSystemDataBackupCollector().collect(system_context) self.assertEqual( CollectorStatus.CANCELLED, @@ -400,11 +351,7 @@ def cancel_system_copy( self.assertEqual( [], - [ - path - for path in system_context.workspace.rglob("*") - if path.is_file() and path != system_cancellation - ], + [path for path in system_context.workspace.rglob("*") if path.is_file() and path != system_cancellation], ) home = root / "home" @@ -421,11 +368,11 @@ def cancel_system_copy( original_write = zipfile.ZipFile.write def cancel_archive_write( - archive: zipfile.ZipFile, - filename: str | os.PathLike[str], - arcname: str | os.PathLike[str] | None = None, - compress_type: int | None = None, - compresslevel: int | None = None, + archive: zipfile.ZipFile, + filename: str | os.PathLike[str], + arcname: str | os.PathLike[str] | None = None, + compress_type: int | None = None, + compresslevel: int | None = None, ) -> None: bound_write = original_write.__get__(archive, type(archive)) @@ -450,26 +397,17 @@ def cancel_archive_write( new=cancel_archive_write, ), ): - ssh_result = ( - ssh_backup - .SshBackupCollector() - .collect(ssh_context) - ) + ssh_result = ssh_backup.SshBackupCollector().collect(ssh_context) self.assertEqual( CollectorStatus.CANCELLED, ssh_result.status, ) - self.assertFalse( - ( - ssh_context.workspace - / "ssh_backup.zip" - ).exists() - ) + self.assertFalse((ssh_context.workspace / "ssh_backup.zip").exists()) def test_packet_and_memory_loops_stop_before_serialization( - self, + self, ) -> None: """Packet and memory loops stop before serializing evidence.""" @@ -496,19 +434,19 @@ def bind(_address: object) -> None: @staticmethod def setsockopt( - *_args: object, + *_args: object, ) -> None: return None @staticmethod def ioctl( - *_args: object, + *_args: object, ) -> None: return None @staticmethod def recvfrom( - _buffer_size: int, + _buffer_size: int, ) -> tuple[bytes, tuple[str, int]]: return b"", ("127.0.0.1", 0) @@ -517,8 +455,8 @@ def close() -> None: return None def cancel_wait( - *_args: object, - **_kwargs: object, + *_args: object, + **_kwargs: object, ) -> tuple[ list[object], list[object], @@ -540,23 +478,14 @@ def cancel_wait( side_effect=cancel_wait, ), ): - packet_result = ( - packet_capture - .PacketCaptureCollector() - .collect(packet_context) - ) + packet_result = packet_capture.PacketCaptureCollector().collect(packet_context) self.assertEqual( CollectorStatus.CANCELLED, packet_result.status, ) - self.assertFalse( - ( - packet_context.workspace - / "packet_capture.csv" - ).exists() - ) + self.assertFalse((packet_context.workspace / "packet_capture.csv").exists()) memory_context, memory_cancellation = _context( root / "memory", @@ -574,24 +503,24 @@ def cancel_on_current_process() -> int: memory_cancellation.touch() return 1 - with patch.object( + with ( + patch.object( memory_map, "get_current_process", side_effect=cancel_on_current_process, - ), patch.object( + ), + patch.object( memory_map, "get_process_memory_info", return_value=True, - ), patch.object( + ), + patch.object( memory_map, "virtual_query", return_value=0, + ), ): - memory_result = ( - memory_map - .MemoryMapCollector() - .collect(memory_context) - ) + memory_result = memory_map.MemoryMapCollector().collect(memory_context) self.assertEqual( CollectorStatus.CANCELLED, @@ -600,11 +529,7 @@ def cancel_on_current_process() -> int: self.assertEqual( [], - [ - path - for path in memory_context.workspace.rglob("*") - if path.is_file() and path != memory_cancellation - ], + [path for path in memory_context.workspace.rglob("*") if path.is_file() and path != memory_cancellation], ) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 468a9c47..16fc4051 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -8,6 +8,7 @@ from typing import Any, cast from logicytics.contracts import ( + Capability, RunRequest, ) from logicytics.module.configuration import ( @@ -16,7 +17,6 @@ ) from logicytics.module.discovery import preflight from logicytics.module.errors import PlanError -from logicytics.contracts import Capability from logicytics.module.planner import build_plan from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import COLLECTOR @@ -32,15 +32,17 @@ def test_runtime_blocked_capabilities_are_typed_and_default_empty(self) -> None: self.assertEqual((), default_config(root).runtime.blocked_capabilities) config_path = root / "logicytics.yaml" config_path.write_text( - json.dumps({ - "schema_version": 4, - "runtime": { - "blocked_capabilities": { - "network": True, - "subprocess": False, + json.dumps( + { + "schema_version": 4, + "runtime": { + "blocked_capabilities": { + "network": True, + "subprocess": False, + }, }, - }, - }), + } + ), encoding="utf-8", ) configuration = load_config(root) @@ -196,7 +198,9 @@ def test_configuration_defaults_to_one_canonical_output_data_root(self) -> None: self.assertEqual(root / "custom" / "evidence", load_config(root).runtime.output_root) self.assertFalse(expected.exists()) - def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings(self) -> None: + def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings( + self, + ) -> None: """Manifest snapshots hide credentials while collectors retain configured access.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -233,8 +237,14 @@ def test_configuration_rejects_boolean_workers_and_invalid_output_roots(self) -> ('{"schema_version":true}', "schema_version"), ('{"schema_version":4,"runtime":{"default_max_workers":true}}', "worker limits"), ('{"schema_version":4,"runtime":{"maximum_workers":true}}', "worker limits"), - ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":true}}', "maximum_run_output_bytes"), - ('{"schema_version":4,"runtime":{"maximum_run_output_bytes":0}}', "maximum_run_output_bytes"), + ( + '{"schema_version":4,"runtime":{"maximum_run_output_bytes":true}}', + "maximum_run_output_bytes", + ), + ( + '{"schema_version":4,"runtime":{"maximum_run_output_bytes":0}}', + "maximum_run_output_bytes", + ), ('{"schema_version":4,"runtime":{"output_root":false}}', "output_root"), ('{"schema_version":4,"runtime":{"output_root":" "}}', "output_root"), ) @@ -254,12 +264,19 @@ def test_configuration_rejects_unknown_duplicate_and_non_finite_values(self) -> ('{"schema_version":4,"runtime":{"worker_typo":2}}', "unsupported settings"), ('{"schema_version":4,"schema_version":4}', "duplicate configuration key"), ('{"schema_version":4,"runtime":{"maximum_workers":NaN}}', "non-finite"), - ('{"schema_version":4,"collectors":{"plugin.custom":{"value":1e999}}}', "non-finite"), + ( + '{"schema_version":4,"collectors":{"plugin.custom":{"value":1e999}}}', + "non-finite", + ), ('{"schema_version":4,"collectors":{"../escape":{}}}', "invalid collector ID"), - ('{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', - "invalid setting name"), - ('{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', - "unsupported settings"), + ( + '{"schema_version":4,"collectors":{"core.system.example":{"invalid-name":1}}}', + "invalid setting name", + ), + ( + '{"schema_version":4,"collectors":{"core.packet.packet_capture":{"packet_typo":1}}}', + "unsupported settings", + ), ) for payload, message in invalid: with self.subTest(payload=payload): @@ -283,7 +300,11 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) ("core.filesystem.system_drive_tree", {"max_depth": 33}, "max_depth"), ("core.filesystem.system_drive_listing", {"workers": 2}, "unsupported settings"), ("core.filesystem.system_drive_listing", {"max_entries": "100"}, "max_entries"), - ("core.filesystem.sensitive_file_inventory", {"max_directories": 50_001}, "max_directories"), + ( + "core.filesystem.sensitive_file_inventory", + {"max_directories": 50_001}, + "max_directories", + ), ("core.filesystem.sensitive_file_inventory", {"max_matches": 0}, "max_matches"), ("core.filesystem.sensitive_file_inventory", {"root": "relative/path"}, "absolute"), ) @@ -300,7 +321,9 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) "core.filesystem.system_drive_tree": {"max_entries": 100, "max_depth": 3}, "core.filesystem.system_drive_listing": {"max_entries": 200, "max_depth": 4}, "core.filesystem.sensitive_file_inventory": { - "root": str(root), "max_directories": 100, "max_matches": 10, + "root": str(root), + "max_directories": 100, + "max_matches": 10, }, } config_path.write_text(json.dumps({"schema_version": 4, "collectors": valid}), encoding="utf-8") @@ -308,7 +331,9 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) for collector_id, settings in valid.items(): self.assertEqual(settings, configuration.settings_for(collector_id)) - def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_paths(self) -> None: + def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_paths( + self, + ) -> None: """The existing metadata-only memory mapper rejects unsafe values before worker launch.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -357,11 +382,13 @@ def test_configuration_profile_collector_and_request_boundaries_are_isolated(sel "dump_directory": "maps", } (root / "logicytics.yaml").write_text( - json.dumps({ - "schema_version": 4, - "runtime": {"default_max_workers": 3, "maximum_workers": 8}, - "collectors": {collector_id: collector_settings}, - }), + json.dumps( + { + "schema_version": 4, + "runtime": {"default_max_workers": 3, "maximum_workers": 8}, + "collectors": {collector_id: collector_settings}, + } + ), encoding="utf-8", ) configuration = load_config(root) @@ -386,8 +413,7 @@ def test_configuration_validates_bounded_network_and_packet_settings(self) -> No root = Path(temporary) config_path = root / "logicytics.yaml" config_path.write_text( - '{"schema_version":4,"collectors":{"core.network.bandwidth_sample":' - '{"sample_count":11}}}', + '{"schema_version":4,"collectors":{"core.network.bandwidth_sample":{"sample_count":11}}}', encoding="utf-8", ) with self.assertRaisesRegex(PlanError, "sample_count"): diff --git a/tests/test_configuration_documentation.py b/tests/test_configuration_documentation.py index 61a1437d..7c94ea4d 100644 --- a/tests/test_configuration_documentation.py +++ b/tests/test_configuration_documentation.py @@ -77,20 +77,24 @@ def test_core_settings_require_an_explicit_schema_while_extensions_remain_open(s root = Path(temporary) path = root / "logicytics.yaml" path.write_text( - json.dumps({ - "schema_version": 4, - "collectors": {"core.system.system_info": {"typo": 1}}, - }), + json.dumps( + { + "schema_version": 4, + "collectors": {"core.system.system_info": {"typo": 1}}, + } + ), encoding="utf-8", ) with self.assertRaisesRegex(PlanError, "does not declare configurable settings"): configuration.load_config(root) path.write_text( - json.dumps({ - "schema_version": 4, - "collectors": {"plugin.example": {"extension_option": 1}}, - }), + json.dumps( + { + "schema_version": 4, + "collectors": {"plugin.example": {"extension_option": 1}}, + } + ), encoding="utf-8", ) loaded = configuration.load_config(root) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 374222d0..43125565 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -19,19 +19,30 @@ class ContractTests(unittest.TestCase): def test_custom_specialty_is_plugin_only(self) -> None: """Plugins may extend specialties, but core metadata remains on the closed set.""" metadata = CollectorMetadata( - id="plugin.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, - description="Test metadata.", author="Test", + id="plugin.example", + name="Example", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Test metadata.", + author="Test", ).to_dict() metadata["specialty"] = "evidence_graph" - self.assertEqual("evidence_graph", CollectorMetadata.from_dict(metadata, allow_custom_specialty=True).specialty) + self.assertEqual( + "evidence_graph", + CollectorMetadata.from_dict(metadata, allow_custom_specialty=True).specialty, + ) with self.assertRaises(ValueError): CollectorMetadata.from_dict(metadata) def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: """Collector metadata must be a complete typed declaration rather than free text.""" common = dict( - id="core.system.example", name="Example", version="4.0.0", specialty=Specialty.SYSTEM, - description="Example collector.", author="tests", + id="core.system.example", + name="Example", + version="4.0.0", + specialty=Specialty.SYSTEM, + description="Example collector.", + author="tests", ) with self.assertRaisesRegex(ValueError, "id has an invalid schema"): CollectorMetadata(**{**common, "id": "example"}) @@ -64,16 +75,25 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: self.assertIs(ResourceClass.GENERAL, metadata.resource_class) disk_metadata = CollectorMetadata(**{**common, "resource_class": ResourceClass.DISK_HEAVY}) self.assertEqual("disk_heavy", disk_metadata.to_dict()["resource_class"]) - self.assertIs(ResourceClass.DISK_HEAVY, CollectorMetadata.from_dict(disk_metadata.to_dict()).resource_class) + self.assertIs( + ResourceClass.DISK_HEAVY, + CollectorMetadata.from_dict(disk_metadata.to_dict()).resource_class, + ) def test_collector_results_expose_all_strict_typed_terminal_states(self) -> None: """Every terminal collector outcome has an explicit, validated result constructor.""" outcomes = ( (CollectorResult.succeeded("complete"), CollectorStatus.SUCCEEDED), - (CollectorResult.partial("incomplete", errors=("one source unavailable",)), CollectorStatus.PARTIAL), + ( + CollectorResult.partial("incomplete", errors=("one source unavailable",)), + CollectorStatus.PARTIAL, + ), (CollectorResult.skipped("prerequisite unavailable"), CollectorStatus.SKIPPED), (CollectorResult.cancelled("operator cancelled"), CollectorStatus.CANCELLED), - (CollectorResult.failed("collection failed", errors=("access denied",)), CollectorStatus.FAILED), + ( + CollectorResult.failed("collection failed", errors=("access denied",)), + CollectorStatus.FAILED, + ), ) for result, status in outcomes: with self.subTest(status=status): @@ -81,12 +101,29 @@ def test_collector_results_expose_all_strict_typed_terminal_states(self) -> None invalid = ( ({"status": "succeeded", "summary": "complete"}, "status"), ({"status": CollectorStatus.SUCCEEDED, "summary": ""}, "summary"), - ({"status": CollectorStatus.SUCCEEDED, "summary": "complete", "artifacts": []}, "artifacts"), - ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ["failure"]}, "errors"), + ( + {"status": CollectorStatus.SUCCEEDED, "summary": "complete", "artifacts": []}, + "artifacts", + ), + ( + {"status": CollectorStatus.FAILED, "summary": "failed", "errors": ["failure"]}, + "errors", + ), ({"status": CollectorStatus.FAILED, "summary": "failed", "errors": ("",)}, "errors"), - ({"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": True}}, "metrics"), ( - {"status": CollectorStatus.PARTIAL, "summary": "partial", "metrics": {"count": float("inf")}}, + { + "status": CollectorStatus.PARTIAL, + "summary": "partial", + "metrics": {"count": True}, + }, + "metrics", + ), + ( + { + "status": CollectorStatus.PARTIAL, + "summary": "partial", + "metrics": {"count": float("inf")}, + }, "metrics", ), ) diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index 4804ca72..acd34c55 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -14,7 +14,12 @@ from core.system import system_details from core.system.bios_info import render_bios_table from logicytics.contracts import ( - Artifact, ArtifactWriter, CollectorContext, EvidenceKind, EventLogger, RunStatus, + Artifact, + ArtifactWriter, + CollectorContext, + EventLogger, + EvidenceKind, + RunStatus, ) from logicytics.module.manifest import CollectorRecord, RunManifest @@ -22,7 +27,7 @@ class _Logger(EventLogger): - def event(self, level: str, message: str, **fields: int | float | str) -> None: + def event(self, level: str, message: str, **fields: float | str) -> None: return None @@ -30,8 +35,14 @@ class _CaptureWriter(ArtifactWriter): def __init__(self) -> None: self.contents = b"" - def register_file(self, source: Path, *, media_type: str = "application/octet-stream", - transformations: tuple[str, ...] = (), **kwargs: object) -> Artifact: + def register_file( + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + **kwargs: object, + ) -> Artifact: self.contents = source.read_bytes() return Artifact( id="artifact." + "0" * 32, @@ -67,9 +78,9 @@ def test_text_output_matches_golden_file(self) -> None: with tempfile.TemporaryDirectory() as temporary: writer = _CaptureWriter() with patch.object( - system_details.subprocess, - system_details.subprocess.run.__name__, - return_value=CompletedProcess([], 0, expected, ""), + system_details.subprocess, + system_details.subprocess.run.__name__, + return_value=CompletedProcess([], 0, expected, ""), ): system_details.SystemDetailsCollector().collect( _context( @@ -85,9 +96,9 @@ def test_csv_output_matches_golden_file(self) -> None: with tempfile.TemporaryDirectory() as temporary: writer = _CaptureWriter() with patch.object( - running_processes.subprocess, - running_processes.subprocess.run.__name__, - return_value=CompletedProcess([], 0, expected, ""), + running_processes.subprocess, + running_processes.subprocess.run.__name__, + return_value=CompletedProcess([], 0, expected, ""), ): running_processes.RunningProcessesCollector().collect( _context( @@ -99,13 +110,15 @@ def test_csv_output_matches_golden_file(self) -> None: self.assertEqual(expected.encode(), writer.contents) def test_html_output_matches_golden_file(self) -> None: - rendered = render_bios_table({ - "Manufacturer": "Example & Sons", - "Name": "Golden ", - "SMBIOSBIOSVersion": "1.2.3", - "Version": "EXAMPLE - 1", - "ReleaseDate": "2026-01-02", - }) + rendered = render_bios_table( + { + "Manufacturer": "Example & Sons", + "Name": "Golden ", + "SMBIOSBIOSVersion": "1.2.3", + "Version": "EXAMPLE - 1", + "ReleaseDate": "2026-01-02", + } + ) self.assertEqual((_GOLDEN / "bios_info.html").read_text(encoding="utf-8"), rendered) def test_graph_output_matches_golden_file(self) -> None: @@ -123,12 +136,23 @@ def test_normalized_manifest_matches_golden_shape(self) -> None: status=RunStatus.SUCCEEDED, request={"profile": "minimal"}, configuration={"schema_version": 4}, - collectors=[CollectorRecord( - id="core.system.system_info", source="", status="succeeded", - started_at="", finished_at="", summary="collected", - )], - host={"platform": "win32", "hostname": "", "python": "", - "user": "", "is_administrator": "false"}, + collectors=[ + CollectorRecord( + id="core.system.system_info", + source="", + status="succeeded", + started_at="", + finished_at="", + summary="collected", + ) + ], + host={ + "platform": "win32", + "hostname": "", + "python": "", + "user": "", + "is_administrator": "false", + }, resolved_plan=("core.system.system_info",), plan_fingerprint="0" * 64, finished_at="", diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 27b7c982..758235e1 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -23,9 +23,12 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( + with ( + patch.object(CLI, "project_root", return_value=root), + patch( "sys.stderr", output, + ), ): self.assertEqual(0, main(["--match", "run a quick basic collection"])) rendered = output.getvalue() @@ -37,21 +40,26 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel self.assertFalse((root / "output" / "data" / "interaction_history.json.gz").exists()) (root / "logicytics.yaml").write_text( - json.dumps({ - "schema_version": 4, - "interaction": { - "history_enabled": True, - "similarity_threshold": 0.5, - "model_name": "stdlib-test-model", - "model_debug": True, - }, - }), + json.dumps( + { + "schema_version": 4, + "interaction": { + "history_enabled": True, + "similarity_threshold": 0.5, + "model_name": "stdlib-test-model", + "model_debug": True, + }, + } + ), encoding="utf-8", ) output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( + with ( + patch.object(CLI, "project_root", return_value=root), + patch( "sys.stderr", output, + ), ): self.assertEqual(0, main(["--match", "an exhaustive slow scan"])) rendered = output.getvalue() @@ -67,9 +75,12 @@ def test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in(sel self.assertIn("device_name", history[0]) output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( + with ( + patch.object(CLI, "project_root", return_value=root), + patch( "sys.stderr", output, + ), ): self.assertEqual(0, main(["--usage"])) rendered = output.getvalue() diff --git a/tests/test_legacy_code_outputs_collector.py b/tests/test_legacy_code_outputs_collector.py index 9eb4cc3c..5eef7435 100644 --- a/tests/test_legacy_code_outputs_collector.py +++ b/tests/test_legacy_code_outputs_collector.py @@ -8,8 +8,8 @@ from unittest.mock import MagicMock, patch from core.integration.legacy_code_outputs import LegacyCodeOutputsCollector -from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.module.artifacts import WorkspaceArtifactWriter class LegacyCodeOutputsCollectorTests(unittest.TestCase): @@ -72,9 +72,7 @@ def test_generated_evidence_is_imported_while_project_material_is_excluded(self) self.assertIs(CollectorStatus.SUCCEEDED, result.status) self.assertEqual({"report.txt", "events.csv"}, {artifact.name for artifact in result.artifacts}) - stored = { - path.name for path in artifact_root.rglob("*") if path.is_file() - } + stored = {path.name for path in artifact_root.rglob("*") if path.is_file()} self.assertEqual({"report.txt", "events.csv"}, stored) for name in excluded: self.assertFalse(any(path.name == name for path in workspace.rglob("*"))) diff --git a/tests/test_logging.py b/tests/test_logging.py index 495ee26f..4746ccaa 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -26,7 +26,10 @@ timed, ) from logicytics.module.output_layout import ensure_output_layout -from logicytics.virtual_environment import render_virtual_environment_error, virtual_environment_error +from logicytics.virtual_environment import ( + render_virtual_environment_error, + virtual_environment_error, +) from tests.fixtures.file_listing import list_files @@ -43,9 +46,7 @@ def test_application_sources_do_not_call_print(self) -> None: violations.extend( f"{source.relative_to(root)}:{node.lineno}" for node in ast.walk(tree) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == "print" + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == "print" ) self.assertEqual([], violations) @@ -97,9 +98,7 @@ def isatty(self) -> bool: retention_days=1, ) logger = ApplicationLogger(path, settings, console=console) - for level in ( - "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION" - ): + for level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "INTERNAL", "EXCEPTION"): logger.event(level, "typed event", password="hidden", sequence=1) logger.dispatch(("WARNING: parsed warning", "plain batch row")) logger.raw("raw access_token=hidden") @@ -158,28 +157,30 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self root = Path(temporary) layout = ensure_output_layout(root / "output" / "data") for directory in ( - layout.data, - layout.logs, - layout.debug_logs, - layout.performance_logs, - layout.packages, - layout.hashes, + layout.data, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, ): self.assertTrue(directory.is_dir()) config_path = root / "logicytics.yaml" config_path.write_text( - json.dumps({ - "schema_version": 4, - "logging": { - "level": "debug", - "console_enabled": False, - "color_enabled": False, - "file_enabled": True, - "maximum_bytes": 2048, - "delete_previous": True, - "retention_days": 7, - }, - }), + json.dumps( + { + "schema_version": 4, + "logging": { + "level": "debug", + "console_enabled": False, + "color_enabled": False, + "file_enabled": True, + "maximum_bytes": 2048, + "delete_previous": True, + "retention_days": 7, + }, + } + ), encoding="utf-8", ) configuration = load_config(root, config_path) @@ -188,11 +189,11 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self self.assertEqual(7, configuration.logging.retention_days) for invalid_logging in ( - {"level": "TRACE"}, - {"maximum_bytes": True}, - {"retention_days": -1}, - {"console_enabled": 1}, - {"unknown": True}, + {"level": "TRACE"}, + {"maximum_bytes": True}, + {"retention_days": -1}, + {"console_enabled": 1}, + {"unknown": True}, ): config_path.write_text( json.dumps({"schema_version": 4, "logging": invalid_logging}), @@ -253,7 +254,7 @@ def test_application_logging_humanizes_structured_values_and_lowercase_settings( self.assertIn("Status: complete", output) self.assertIn("Capabilities: network, subprocess", output) self.assertIn("Payload: <15 bytes>", output) - self.assertNotIn("{\"status\"", output) + self.assertNotIn('{"status"', output) self.assertNotIn("capabilities=", output) self.assertNotIn("secret-value", output) diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index ab49c66f..6f5b8273 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -9,10 +9,10 @@ from pathlib import Path from unittest.mock import MagicMock, patch +from logicytics.cli import CLI, main from logicytics.module import ( maintenance, ) -from logicytics.cli import CLI, main from logicytics.module.configuration import ( MaintenanceSettings, load_config, @@ -33,7 +33,7 @@ class MaintenanceTests(unittest.TestCase): """Maintenance, integrity manifests, developer actions, and Sysinternals behavior.""" def test_maintenance_configuration_requires_pinned_https_and_python_order( - self, + self, ) -> None: """Integrity endpoints and Python policy fail closed during configuration loading.""" with tempfile.TemporaryDirectory() as temporary: @@ -51,16 +51,14 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( } config.write_text(json.dumps(valid), encoding="utf-8") loaded = load_config(root, config) - self.assertEqual( - Path("integrity/project.json"), loaded.maintenance.local_manifest_path - ) + self.assertEqual(Path("integrity/project.json"), loaded.maintenance.local_manifest_path) self.assertEqual("3.12", loaded.maintenance.recommended_python) for mutation in ( - {"remote_manifest_url": "http://example.invalid/project.json"}, - {"remote_manifest_sha256": None}, - {"local_manifest_path": "../escape.json"}, - {"minimum_python": "3.12", "recommended_python": "3.11"}, + {"remote_manifest_url": "http://example.invalid/project.json"}, + {"remote_manifest_sha256": None}, + {"local_manifest_path": "../escape.json"}, + {"minimum_python": "3.12", "recommended_python": "3.11"}, ): invalid = json.loads(json.dumps(valid)) invalid["maintenance"].update(mutation) @@ -69,7 +67,7 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( load_config(root, config) def test_authenticated_remote_manifest_is_strict_data_only_configuration( - self, + self, ) -> None: """Pinned HTTPS bytes are accepted, while unpinned or execution-bearing data is rejected.""" payload = json.dumps( @@ -86,9 +84,9 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( response = MagicMock() response.__enter__.return_value.read.return_value = payload with patch.object( - maintenance.urllib.request, - "urlopen", - return_value=response, + maintenance.urllib.request, + "urlopen", + return_value=response, ): manifest = fetch_remote_manifest(settings) @@ -100,13 +98,15 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( remote_manifest_url=settings.remote_manifest_url, remote_manifest_sha256="0" * 64, ) - with patch.object( + with ( + patch.object( maintenance.urllib.request, "urlopen", return_value=response, + ), + self.assertRaisesRegex(ValueError, "does not match"), ): - with self.assertRaisesRegex(ValueError, "does not match"): - fetch_remote_manifest(tampered) + fetch_remote_manifest(tampered) execution_payload = json.dumps( { @@ -125,13 +125,15 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( remote_manifest_sha256=hashlib.sha256(execution_payload).hexdigest(), ) - with patch.object( + with ( + patch.object( maintenance.urllib.request, "urlopen", return_value=execution_response, + ), + self.assertRaisesRegex(ValueError, "only schema_version"), ): - with self.assertRaisesRegex(ValueError, "only schema_version"): - fetch_remote_manifest(execution_settings) + fetch_remote_manifest(execution_settings) def test_integrity_manifest_comparison_and_snapshot_version_ordering(self) -> None: """Developer integrity reports file states and compare snapshots semantically.""" @@ -152,9 +154,7 @@ def test_integrity_manifest_comparison_and_snapshot_version_ordering(self) -> No self.assertEqual(["added.txt"], comparison["extra"]) self.assertEqual(["same.txt"], comparison["unchanged"]) self.assertEqual("behind", compare_versions("4.1.0-snapshot.2", "4.1.0")) - self.assertEqual( - "behind", compare_versions("4.1.0-snapshot.2", "4.1.0-snapshot.10") - ) + self.assertEqual("behind", compare_versions("4.1.0-snapshot.2", "4.1.0-snapshot.10")) cache = root / ".pytest_cache" / "ignored.txt" cache.parent.mkdir() cache.write_text("ignored", encoding="utf-8") @@ -177,9 +177,12 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No "schema_version: 4\nmaintenance:\n sysinternals_enabled: false\n", encoding="utf-8", ) - with patch.object(CLI, "project_root", return_value=root), patch( + with ( + patch.object(CLI, "project_root", return_value=root), + patch( "sys.stdout", new_callable=io.StringIO, + ), ): self.assertEqual( 0, @@ -191,9 +194,12 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No manifest_payload = json.loads(manifest_path.read_text(encoding="utf-8")) self.assertEqual("4.1.0", manifest_payload["version"]) - with patch.object(CLI, "project_root", return_value=root), patch( + with ( + patch.object(CLI, "project_root", return_value=root), + patch( "sys.stdout", new_callable=io.StringIO, + ), ): self.assertEqual(0, main(["debug"])) debug_path = root / "output" / "logs" / "debug" / "debug.json" @@ -220,18 +226,16 @@ def test_dev_writes_manifest_without_mutating_legacy_ini(self) -> None: code.mkdir() legacy = code / "config.ini" legacy.write_text( - "# preserve this comment\n" - "[System Settings]\n" - "version = 3.6.0\n" - 'files = "old.py"\n\n' - "[Unrelated]\n" - "value = untouched\n", + '# preserve this comment\n[System Settings]\nversion = 3.6.0\nfiles = "old.py"\n\n[Unrelated]\nvalue = untouched\n', encoding="utf-8", ) output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( + with ( + patch.object(CLI, "project_root", return_value=root), + patch( "sys.stderr", output, + ), ): self.assertEqual( 0, diff --git a/tests/test_mods.py b/tests/test_mods.py index f3734ea1..e4f9c9a8 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -7,10 +7,10 @@ from pathlib import Path from logicytics.cli import cli_methods -from logicytics.module.configuration import default_config from logicytics.contracts import Capability, RunRequest +from logicytics.module.configuration import default_config from logicytics.module.discovery import preflight -from logicytics.module.errors import PlanError, PreflightError +from logicytics.module.errors import PreflightError from logicytics.module.planner import build_plan from logicytics.module.runtime import RunSupervisor from tests.fixtures.collectors import mod_metadata @@ -88,17 +88,9 @@ def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> ) self.assertEqual(2, len(record.artifacts)) - report_artifact = next( - item - for item in record.artifacts - if item["name"] == "report.txt" - ) + report_artifact = next(item for item in record.artifacts if item["name"] == "report.txt") - artifact_path = ( - outcome.run_directory - / "artifacts" - / str(report_artifact["relative_path"]) - ) + artifact_path = outcome.run_directory / "artifacts" / str(report_artifact["relative_path"]) self.assertEqual( "mod evidence\n", @@ -112,9 +104,7 @@ def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> mods_package = Path(package["mods_path"]) mods_hash = Path(package["mods_sha256_path"]) - self.assertTrue( - mods_package.name.startswith("mods-run-") - ) + self.assertTrue(mods_package.name.startswith("mods-run-")) self.assertTrue(mods_hash.is_file()) with zipfile.ZipFile(mods_package) as archive: @@ -124,16 +114,11 @@ def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> "metadata/mods.json", names, ) - self.assertTrue( - any( - name.endswith("/report.txt") - for name in names - ) - ) + self.assertTrue(any(name.endswith("/report.txt") for name in names)) self.assertIsNone(archive.testzip()) def test_python_mod_cannot_mutate_project_configuration_without_write_approval( - self, + self, ) -> None: """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" with tempfile.TemporaryDirectory() as temporary: @@ -149,15 +134,8 @@ def test_python_mod_cannot_mutate_project_configuration_without_write_approval( mods.mkdir() scripts = { - "good.py": ( - "from pathlib import Path\n" - "Path('report.txt').write_text('ok', encoding='utf-8')\n" - ), - "malicious.py": ( - "from pathlib import Path\n" - f"Path({str(configuration_path)!r}).write_text(" - "'replaced', encoding='utf-8')\n" - ), + "good.py": ("from pathlib import Path\nPath('report.txt').write_text('ok', encoding='utf-8')\n"), + "malicious.py": (f"from pathlib import Path\nPath({str(configuration_path)!r}).write_text('replaced', encoding='utf-8')\n"), } for filename, source in scripts.items(): @@ -193,10 +171,7 @@ def test_python_mod_cannot_mutate_project_configuration_without_write_approval( default_config(root), ).run(plan) - records = { - record.id: record - for record in outcome.manifest.collectors - } + records = {record.id: record for record in outcome.manifest.collectors} self.assertEqual( "succeeded", @@ -208,10 +183,7 @@ def test_python_mod_cannot_mutate_project_configuration_without_write_approval( records["mod.malicious"].status, ) self.assertTrue( - any( - "private workspace" in error - for error in records["mod.malicious"].errors - ), + any("private workspace" in error for error in records["mod.malicious"].errors), records["mod.malicious"].errors, ) diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 125833be..733f5fdb 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -6,21 +6,22 @@ import tempfile import unittest import zipfile +from collections.abc import Callable from dataclasses import replace from datetime import datetime from pathlib import Path -from typing import Any, Callable, cast +from typing import Any, cast from unittest.mock import patch -from logicytics.module import packaging from logicytics.cli import cli_methods +from logicytics.contracts import ( + RunRequest, +) +from logicytics.module import packaging from logicytics.module.configuration import ( default_config, load_config, ) -from logicytics.contracts import ( - RunRequest, -) from logicytics.module.discovery import preflight from logicytics.module.manifest import write_manifest from logicytics.module.packaging import package_run @@ -108,9 +109,7 @@ def capture_manifest_state(path, manifest): self.assertNotIn("logs/performance.json", archive.namelist()) self.assertEqual(1, len([name for name in archive.namelist() if name.startswith("evidence/")])) artifact = outcome.manifest.artifact_list()[0] - archived_bytes = archive.read( - f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}" - ) + archived_bytes = archive.read(f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}") self.assertEqual(artifact.size_bytes, len(archived_bytes)) self.assertEqual(artifact.sha256, hashlib.sha256(archived_bytes).hexdigest()) self.assertEqual("system", artifact.source_category) @@ -162,22 +161,26 @@ def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) collector_path = root / "core" / "system" / "system_info.py" collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() - source = COLLECTOR.replace( - "CollectorResult, CoreCollector, Specialty", - "CollectorResult, CoreCollector, EvidenceKind, Specialty", - ).replace( - 'output_media_types=("text/plain",)', - 'output_media_types=("text/plain", "application/octet-stream")', - ).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raw = context.workspace / "source.bin"\n' - ' raw.write_bytes(b"raw evidence")\n' - ' raw_artifact = context.artifacts.register_file(\n' - ' raw, evidence_kind=EvidenceKind.RAW,\n' - ' )\n' - ' return CollectorResult.succeeded(\n' - ' "typed artifacts created", (artifact, raw_artifact),\n' - ' )', + source = ( + COLLECTOR.replace( + "CollectorResult, CoreCollector, Specialty", + "CollectorResult, CoreCollector, EvidenceKind, Specialty", + ) + .replace( + 'output_media_types=("text/plain",)', + 'output_media_types=("text/plain", "application/octet-stream")', + ) + .replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raw = context.workspace / "source.bin"\n' + ' raw.write_bytes(b"raw evidence")\n' + " raw_artifact = context.artifacts.register_file(\n" + " raw, evidence_kind=EvidenceKind.RAW,\n" + " )\n" + " return CollectorResult.succeeded(\n" + ' "typed artifacts created", (artifact, raw_artifact),\n' + " )", + ) ) collector_path.write_text(source, encoding="utf-8") report = preflight(root) @@ -227,7 +230,9 @@ def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) with self.assertRaisesRegex(ValueError, "unsupported run manifest schema_version"): outcome.manifest.to_dict() - def test_configured_output_root_colocates_packages_without_touching_legacy_evidence(self) -> None: + def test_configured_output_root_colocates_packages_without_touching_legacy_evidence( + self, + ) -> None: """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -271,11 +276,11 @@ def test_partial_collector_evidence_is_packaged_and_clearly_labeled(self) -> Non collector_path.write_text( COLLECTOR.replace( ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' return CollectorResult.partial(\n' + " return CollectorResult.partial(\n" ' "only one evidence source was available",\n' - ' (artifact,),\n' + " (artifact,),\n" ' errors=("secondary evidence source was unavailable",),\n' - ' )', + " )", ), encoding="utf-8", ) @@ -321,9 +326,7 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( report = preflight(root) self.assertEqual((), report.invalid) configuration = default_config(root) - original = RunSupervisor(root, configuration).run( - build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True)) - ) + original = RunSupervisor(root, configuration).run(build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True))) original_package_metadata = original.manifest.package assert original_package_metadata is not None original_manifest = original.manifest_path.read_bytes() @@ -370,7 +373,9 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( self.assertIn("Action: rerun", summary) self.assertIn(f"Parent run: {original.manifest.run_id}", summary) - def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted(self) -> None: + def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted( + self, + ) -> None: """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -383,12 +388,14 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r ' password = context.settings["password"]\n' ' token = context.settings["nested"]["access_token"]\n' ' output = context.workspace / "system.txt"', - ).replace( + ) + .replace( ' artifact = context.artifacts.register_file(output, media_type="text/plain")', ' output.write_text(f"password={password} token={token}", encoding="utf-8")\n' ' context.logger.event("info", f"password={password}", access_token=token)\n' ' artifact = context.artifacts.register_file(output, media_type="text/plain")', - ).replace( + ) + .replace( ' return CollectorResult.succeeded("test artifact created", (artifact,))', ' return CollectorResult.succeeded(f"password={password}", (artifact,))', ), @@ -411,12 +418,15 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) configuration = replace( default_config(root), - collector_settings=cast(Any, { - "core.system.system_info": { - "password": "evidence-password", - "nested": {"access_token": "evidence-token"}, - } - }), + collector_settings=cast( + Any, + { + "core.system.system_info": { + "password": "evidence-password", + "nested": {"access_token": "evidence-token"}, + } + }, + ), ) outcome = RunSupervisor(root, configuration).run(plan) self.assertEqual("succeeded", outcome.manifest.status.value) @@ -430,9 +440,7 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r archive.read(f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}").decode("utf-8"), ) diagnostics = "\n".join( - archive.read(name).decode("utf-8") - for name in archive.namelist() - if not name.startswith("evidence/") + archive.read(name).decode("utf-8") for name in archive.namelist() if not name.startswith("evidence/") ) self.assertNotIn("evidence-password", diagnostics) self.assertNotIn("evidence-token", diagnostics) @@ -475,9 +483,7 @@ def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) - packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] self.assertEqual(failure, packaged_record["failure"]) diagnostics = "\n".join( - archive.read(name).decode("utf-8") - for name in archive.namelist() - if not name.startswith("evidence/") + archive.read(name).decode("utf-8") for name in archive.namelist() if not name.startswith("evidence/") ) self.assertNotIn("crash-password", diagnostics) self.assertNotIn("crash-token", diagnostics) @@ -515,7 +521,9 @@ def test_performance_report_is_finalized_before_automatic_packaging(self) -> Non with self.assertRaisesRegex(FileNotFoundError, "performance report"): package_run(outcome) - def test_collector_resource_progress_persists_in_manifest_summary_and_performance_report(self) -> None: + def test_collector_resource_progress_persists_in_manifest_summary_and_performance_report( + self, + ) -> None: """Incremental progress fields remain visible live and in every packaged diagnostic.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -523,16 +531,18 @@ def test_collector_resource_progress_persists_in_manifest_summary_and_performanc collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text( - COLLECTOR.replace("from pathlib import Path\n", - "from pathlib import Path\nfrom time import sleep\n").replace( + COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nfrom time import sleep\n", + ).replace( ' output = context.workspace / "system.txt"', ' context.report_progress("scan_started", scanned_files=3)\n' - ' sleep(0.2)\n' - ' context.report_progress(\n' + " sleep(0.2)\n" + " context.report_progress(\n" ' "scan_finished", scanned_files=9, copied_files=4, bytes_written=25,\n' - ' observation_count=6, event_count=11,\n' - ' )\n' - ' sleep(0.2)\n' + " observation_count=6, event_count=11,\n" + " )\n" + " sleep(0.2)\n" ' output = context.workspace / "system.txt"', ), encoding="utf-8", @@ -563,8 +573,7 @@ def capture_manifest(path: Path, manifest: Any) -> None: self.assertGreater(float(record.progress["elapsed_seconds"]), 0) self.assertTrue( any( - snapshot["collectors"][0]["status"] == "running" - and snapshot["collectors"][0]["progress"]["files_scanned"] >= 3 + snapshot["collectors"][0]["status"] == "running" and snapshot["collectors"][0]["progress"]["files_scanned"] >= 3 for snapshot in snapshots ) ) @@ -604,9 +613,9 @@ def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self original_write = packaging._stream_archive_member def tampering_write( - archive: zipfile.ZipFile, - filename: Path, - arcname: str, + archive: zipfile.ZipFile, + filename: Path, + arcname: str, ) -> None: if arcname.startswith("evidence/"): archive.writestr(arcname, b"tampered artifact bytes") @@ -675,7 +684,9 @@ def fail_sidecar(source: str | Path, destination: str | Path) -> None: self.assertFalse(hash_path.with_suffix(".sha256.tmp").exists()) self.assertFalse(hash_path.with_suffix(".sha256.backup").exists()) - def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership(self) -> None: + def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership( + self, + ) -> None: """A modified manifest cannot package outside files or another collector's evidence.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -732,8 +743,7 @@ def test_package_preserves_nested_registered_artifact_directories(self) -> None: collector_path.write_text( COLLECTOR.replace( ' output = context.workspace / "system.txt"', - ' output = context.workspace / "nested" / "reports" / "system.txt"\n' - ' output.parent.mkdir(parents=True)', + ' output = context.workspace / "nested" / "reports" / "system.txt"\n output.parent.mkdir(parents=True)', ), encoding="utf-8", ) @@ -852,11 +862,11 @@ def __init__(self, stream: Any, path: Path) -> None: self.stream = stream self.path = path - def __enter__(self) -> "BoundedReader": + def __enter__(self) -> BoundedReader: self.stream.__enter__() return self - def __exit__(self, *arguments: Any) -> Any: + def __exit__(self, *arguments: object) -> Any: return self.stream.__exit__(*arguments) def read(self, size: int = -1) -> bytes: @@ -868,8 +878,7 @@ def read(self, size: int = -1) -> bytes: def guarded_open(path: Path, *arguments: Any, **options: Any) -> Any: stream = original_open(path, *arguments, **options) resolved_path = path.resolve() - if resolved_path in {source.resolve(), expected_package_path.resolve()} \ - and arguments and arguments[0] == "rb": + if resolved_path in {source.resolve(), expected_package_path.resolve()} and arguments and arguments[0] == "rb": return BoundedReader(stream, path) return stream diff --git a/tests/test_planning.py b/tests/test_planning.py index 8b538404..7a90860a 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -9,10 +9,6 @@ from unittest.mock import patch from logicytics.cli import CLI, cli_methods, main -from logicytics.module.configuration import ( - default_config, - load_config, -) from logicytics.contracts import ( Capability, OutputPolicy, @@ -20,6 +16,10 @@ RunRequest, RunStatus, ) +from logicytics.module.configuration import ( + default_config, + load_config, +) from logicytics.module.discovery import PreflightReport, preflight from logicytics.module.environment import EnvironmentReport from logicytics.module.errors import PlanError @@ -27,8 +27,8 @@ FileEventLogger, ) from logicytics.module.planner import BUILTIN_PROFILES, build_plan -from logicytics.platform_adapters import ProcessAdapter from logicytics.module.runtime import RunSupervisor +from logicytics.platform_adapters import ProcessAdapter from tests.fixtures.collectors import COLLECTOR, delayed_collector_source @@ -46,15 +46,17 @@ def _collector_ids(self, candidates) -> list[str]: return collector_ids def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( - self, + self, ) -> None: """Power actions remain explicit and cannot run before durable package publication.""" - arguments = cli_methods.parser().parse_args([ - "run", - "--shutdown", - "--performance-check", - "--acknowledge-authorization", - ]) + arguments = cli_methods.parser().parse_args( + [ + "run", + "--shutdown", + "--performance-check", + "--acknowledge-authorization", + ] + ) request = cli_methods.request(arguments, default_workers=4) @@ -63,19 +65,23 @@ def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( self.assertEqual(1, request.max_workers) with self.assertRaises(SystemExit): - cli_methods.parser().parse_args([ - "run", - "--reboot", - "--shutdown", - ]) + cli_methods.parser().parse_args( + [ + "run", + "--reboot", + "--shutdown", + ] + ) with self.assertRaisesRegex(ValueError, "require packaged output"): cli_methods.request( - cli_methods.parser().parse_args([ - "run", - "--reboot", - "--no-package", - ]), + cli_methods.parser().parse_args( + [ + "run", + "--reboot", + "--no-package", + ] + ), default_workers=1, ) @@ -105,9 +111,9 @@ def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( ) with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - return_value=completed, + ProcessAdapter, + ProcessAdapter.run.__name__, + return_value=completed, ) as command: RunSupervisor._execute_post_run_action( PostRunAction.REBOOT, @@ -131,14 +137,18 @@ def test_sensitive_collectors_require_explicit_profile_or_include_opt_in(self) - encoding="utf-8", ) sensitive_id = "core.system.z_sensitive" - source = delayed_collector_source("z_sensitive", 0.0).replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.SENSITIVE_FILES,),\n' - ' sensitive_data_categories=("credentials",),\n' - ' default_profiles=("deep",),', + source = ( + delayed_collector_source("z_sensitive", 0.0) + .replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.SENSITIVE_FILES,),\n" + ' sensitive_data_categories=("credentials",),\n' + ' default_profiles=("deep",),', + ) ) (core_directory / "z_sensitive.py").write_text(source, encoding="utf-8") report = preflight(root) @@ -215,14 +225,18 @@ def test_offline_profile_rejects_network_collectors_even_with_explicit_approval( core_directory.mkdir(parents=True) (root / "plugins").mkdir() collector_id = "core.system.network_source" - source = delayed_collector_source("network_source", 0.0).replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.NETWORK,),\n' - ' network_access=NetworkAccess.LOCAL,\n' - ' default_profiles=("standard",),', + source = ( + delayed_collector_source("network_source", 0.0) + .replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.NETWORK,),\n" + " network_access=NetworkAccess.LOCAL,\n" + ' default_profiles=("standard",),', + ) ) (core_directory / "network_source.py").write_text(source, encoding="utf-8") report = preflight(root) @@ -233,7 +247,11 @@ def test_offline_profile_rejects_network_collectors_even_with_explicit_approval( with self.assertRaisesRegex(PlanError, "offline profile prohibits network-capable"): build_plan( report, - RunRequest(profile="offline", include=(collector_id,), approved_capabilities=(Capability.NETWORK,)), + RunRequest( + profile="offline", + include=(collector_id,), + approved_capabilities=(Capability.NETWORK,), + ), ) def test_policy_error_reports_every_selected_collector(self) -> None: @@ -245,21 +263,21 @@ def test_policy_error_reports_every_selected_collector(self) -> None: (root / "plugins").mkdir() for filename, capability in ( - ("a_subprocess", "Capability.SUBPROCESS"), - ("b_network", "Capability.NETWORK"), - ("c_subprocess", "Capability.SUBPROCESS"), + ("a_subprocess", "Capability.SUBPROCESS"), + ("b_network", "Capability.NETWORK"), + ("c_subprocess", "Capability.SUBPROCESS"), ): - source = delayed_collector_source(filename, 0.0).replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - ' capabilities=(),', - f' capabilities=({capability},),\n' - + ( - " network_access=NetworkAccess.LOCAL,\n" - if capability == "Capability.NETWORK" - else "" - ), + source = ( + delayed_collector_source(filename, 0.0) + .replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ) + .replace( + " capabilities=(),", + f" capabilities=({capability},),\n" + + (" network_access=NetworkAccess.LOCAL,\n" if capability == "Capability.NETWORK" else ""), + ) ) (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") @@ -284,9 +302,9 @@ def test_policy_error_reports_every_selected_collector(self) -> None: message.index("core.system.c_subprocess"), ) for collector_id in ( - "core.system.a_subprocess", - "core.system.b_network", - "core.system.c_subprocess", + "core.system.a_subprocess", + "core.system.b_network", + "core.system.c_subprocess", ): self.assertIn(collector_id, message) self.assertIn( @@ -305,8 +323,8 @@ def test_authorization_error_summarizes_categories_and_sensitive_outputs(self) - "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - ' capabilities=(),', - ' capabilities=(Capability.SENSITIVE_FILES,),\n' + " capabilities=(),", + " capabilities=(Capability.SENSITIVE_FILES,),\n" ' sensitive_data_categories=("credentials", "personal_documents"),\n' ' default_profiles=("deep",),', ), @@ -375,7 +393,7 @@ def test_configured_worker_limit_blocks_oversized_run_before_workspace_creation( self.assertFalse(configuration.runtime.output_root.exists()) def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( - self, + self, ) -> None: """Direct execution never pulls unrelated profile members into its supervised run.""" with tempfile.TemporaryDirectory() as temporary: @@ -395,31 +413,32 @@ def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( output = io.StringIO() - with patch.object( + with ( + patch.object( CLI, "project_root", return_value=root, - ), patch( - "sys.stderr", - output, + ), + patch( + "sys.stderr", + output, + ), ): self.assertEqual( 0, - main([ - "collector", - "core.system.first", - "--acknowledge-authorization", - ]), + main( + [ + "collector", + "core.system.first", + "--acknowledge-authorization", + ] + ), ) - manifests = list( - (root / "output" / "data").glob("run-*/manifest.json") - ) + manifests = list((root / "output" / "data").glob("run-*/manifest.json")) self.assertEqual(1, len(manifests)) - manifest = json.loads( - manifests[0].read_text(encoding="utf-8") - ) + manifest = json.loads(manifests[0].read_text(encoding="utf-8")) self.assertEqual( ["core.system.first"], @@ -473,7 +492,13 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( with self.assertRaisesRegex(ValueError, "not present in the original"): cli_methods.request( parser.parse_args( - ["run", "--rerun-from", str(manifest_path), "--include", "core.system.other"] + [ + "run", + "--rerun-from", + str(manifest_path), + "--include", + "core.system.other", + ] ), 2, ) @@ -490,7 +515,13 @@ def test_rerun_request_rejects_unfinalized_unknown_and_unselected_original_work( with self.assertRaisesRegex(ValueError, "unsupported schema_version"): cli_methods.request( parser.parse_args( - ["run", "--rerun-from", str(manifest_path), "--include", collector_id] + [ + "run", + "--rerun-from", + str(manifest_path), + "--include", + collector_id, + ] ), 2, ) @@ -559,21 +590,27 @@ def test_planner_rejects_excluded_missing_and_sensitive_dependency_conflicts(sel ) with self.assertRaisesRegex(PlanError, "explicitly excluded"): build_plan(preflight(root), RunRequest(include=(target_id,), exclude=(dependency_id,))) - sensitive_source = delayed_collector_source("a_dependency", 0.0).replace( - "from logicytics import CollectorMetadata,", - "from logicytics import Capability, CollectorMetadata,", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.SENSITIVE_FILES,),\n' - ' sensitive_data_categories=("credentials",),\n' - ' default_profiles=("deep",),', + sensitive_source = ( + delayed_collector_source("a_dependency", 0.0) + .replace( + "from logicytics import CollectorMetadata,", + "from logicytics import Capability, CollectorMetadata,", + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.SENSITIVE_FILES,),\n" + ' sensitive_data_categories=("credentials",),\n' + ' default_profiles=("deep",),', + ) ) (core_directory / "a_dependency.py").write_text(sensitive_source, encoding="utf-8") report = preflight(root) self.assertEqual((), report.invalid) with self.assertRaisesRegex(PlanError, "sensitive dependency"): - build_plan(report, - RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,))) + build_plan( + report, + RunRequest(include=(target_id,), approved_capabilities=(Capability.SENSITIVE_FILES,)), + ) approved = build_plan( report, RunRequest( @@ -601,8 +638,8 @@ def test_capability_gate_requires_explicit_approval(self) -> None: "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - 'capabilities=(),', - 'capabilities=(Capability.FILESYSTEM_READ,),', + "capabilities=(),", + "capabilities=(Capability.FILESYSTEM_READ,),", ) collector_path.write_text(secured_collector, encoding="utf-8") report = preflight(root) @@ -622,9 +659,8 @@ def test_elevated_collectors_require_approval_and_administrator_privileges(self) "from logicytics import CollectorMetadata", "from logicytics import Capability, CollectorMetadata", ).replace( - 'capabilities=(),', - 'capabilities=(Capability.ELEVATED_PRIVILEGES,), ' - 'privilege_level=PrivilegeLevel.ELEVATED,', + "capabilities=(),", + "capabilities=(Capability.ELEVATED_PRIVILEGES,), privilege_level=PrivilegeLevel.ELEVATED,", ) collector_path.write_text(secured_collector, encoding="utf-8") report = preflight(root) @@ -641,8 +677,8 @@ def test_elevated_collectors_require_approval_and_administrator_privileges(self) with self.assertRaisesRegex(PlanError, "administrator account"): build_plan(report, approved) with patch( - "logicytics.module.planner.inspect_environment", - return_value=EnvironmentReport(True, True, None), + "logicytics.module.planner.inspect_environment", + return_value=EnvironmentReport(True, True, None), ): plan = build_plan(report, approved) self.assertEqual(1, len(plan.collectors)) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 9723e9ba..769709e9 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -2,42 +2,48 @@ from __future__ import annotations -import os import subprocess import sys import tempfile import unittest +from collections.abc import Sequence from pathlib import Path -from typing import Sequence, BinaryIO +from typing import BinaryIO from unittest.mock import Mock, patch from core.packet import packet_capture +from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.module import environment from logicytics.module.artifacts import WorkspaceArtifactWriter -from logicytics.contracts import CollectorContext, CollectorStatus from logicytics.module.environment import inspect_environment from logicytics.platform_adapters import ( - FilesystemAdapter, NetworkAdapter, ProcessAdapter, RegistryAdapter, WindowsApiAdapter, - which, windows_api_adapter, registry_adapter, + FilesystemAdapter, + NetworkAdapter, + ProcessAdapter, + RegistryAdapter, + WindowsApiAdapter, + registry_adapter, + which, + windows_api_adapter, ) class ProcessAdapterTests(unittest.TestCase): def test_process_adapter_normalizes_and_delegates_shell_free_commands(self) -> None: def execute( - command: Sequence[str], - *, - stdout: BinaryIO, - stderr: BinaryIO, - **_options: object, + command: Sequence[str], + *, + stdout: BinaryIO, + stderr: BinaryIO, + **_options: object, ) -> subprocess.CompletedProcess[str]: stdout.write(b"output") stderr.write(b"warning") return subprocess.CompletedProcess(command, 7) with patch( - "logicytics.platform_adapters.subprocess.run", - side_effect=execute, + "logicytics.platform_adapters.subprocess.run", + side_effect=execute, ) as invoke: result = ProcessAdapter().run( ["tool", Path("argument")], @@ -54,11 +60,7 @@ def execute( self.assertEqual(("tool", "argument"), invoke.call_args.args[0]) self.assertEqual( {"check": False, "timeout": 5}, - { - key: value - for key, value in invoke.call_args.kwargs.items() - if key not in {"stdout", "stderr"} - }, + {key: value for key, value in invoke.call_args.kwargs.items() if key not in {"stdout", "stderr"}}, ) def test_process_adapter_rejects_captured_streams_over_the_hard_limit(self) -> None: @@ -70,12 +72,15 @@ def execute(command, *, stdout, stderr, **_options): stderr.write(b"") return subprocess.CompletedProcess(command, 0) - with patch.object(ProcessAdapter, "maximum_capture_bytes", 3), patch( + with ( + patch.object(ProcessAdapter, "maximum_capture_bytes", 3), + patch( "logicytics.platform_adapters.subprocess.run", side_effect=execute, + ), + self.assertRaisesRegex(ValueError, "capture limit"), ): - with self.assertRaisesRegex(ValueError, "capture limit"): - adapter.run(["tool"], capture_output=True, text=True) + adapter.run(["tool"], capture_output=True, text=True) def test_process_adapter_can_capture_inside_an_isolated_workspace(self) -> None: with tempfile.TemporaryDirectory() as temporary: @@ -136,22 +141,27 @@ def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: collector_id="core.packet.packet_capture", workspace=workspace, temporary_directory=workspace / "tmp", - artifacts=WorkspaceArtifactWriter( - "core.packet.packet_capture", workspace, artifacts, 1024 * 1024, 1 - ), + artifacts=WorkspaceArtifactWriter("core.packet.packet_capture", workspace, artifacts, 1024 * 1024, 1), logger=Mock(), - settings={"interface": "192.0.2.1", "packet_count": 2, - "timeout_seconds": 1, "retry_window_seconds": 0}, + settings={ + "interface": "192.0.2.1", + "packet_count": 2, + "timeout_seconds": 1, + "retry_window_seconds": 0, + }, cancellation_file=workspace / ".cancelled", ) - with patch.object( + with ( + patch.object( packet_capture.socket, packet_capture.socket.socket.__name__, return_value=capture, - ), patch.object( - packet_capture.select, - packet_capture.select.select.__name__, - return_value=([capture], [], []), + ), + patch.object( + packet_capture.select, + packet_capture.select.select.__name__, + return_value=([capture], [], []), + ), ): result = packet_capture.PacketCaptureCollector().collect(context) rows = (workspace / "packet_capture.csv").read_text(encoding="utf-8").splitlines() @@ -162,9 +172,9 @@ def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: def test_process_adapter_rejects_shell_empty_and_unbounded_timeout_inputs(self) -> None: adapter = ProcessAdapter() for command, options, message in ( - ([], {}, "at least one"), - (["tool"], {"shell": True}, "shell"), - (["tool"], {"timeout": 0}, "timeout"), + ([], {}, "at least one"), + (["tool"], {"shell": True}, "shell"), + (["tool"], {"timeout": 0}, "timeout"), ): with self.subTest(command=command, options=options): with self.assertRaisesRegex(ValueError, message): @@ -182,12 +192,19 @@ def test_core_collectors_cannot_import_the_process_module_directly(self) -> None def test_application_host_process_and_privilege_access_stays_behind_adapters(self) -> None: project_root = Path(__file__).resolve().parent.parent modules = ( - "cli/commands.py", "module/discovery.py", "module/environment.py", - "module/manifest.py", "module/runtime.py", + "cli/commands.py", + "module/discovery.py", + "module/environment.py", + "module/manifest.py", + "module/runtime.py", ) forbidden = ( - "subprocess.run(", "subprocess.Popen(", "ctypes.windll", "import winreg", - "from shutil import which", "os.killpg(", + "subprocess.run(", + "subprocess.Popen(", + "ctypes.windll", + "import winreg", + "from shutil import which", + "os.killpg(", ) offenders: list[str] = [] for name in modules: @@ -256,27 +273,33 @@ def test_environment_inspection_uses_only_mockable_platform_adapters(self) -> No key.__enter__ = Mock(return_value="uac-key") key.__exit__ = Mock(return_value=False) completed = subprocess.CompletedProcess((), 0, "RemoteSigned\n", "") - with patch.object( + with ( + patch.object( windows_api_adapter, windows_api_adapter.is_administrator.__name__, return_value=True, - ), patch.object( - registry_adapter, - registry_adapter.OpenKey.__name__, - return_value=key, - ), patch.object( - registry_adapter, - registry_adapter.QueryValueEx.__name__, - return_value=(1, 4), - ), patch.object( - environment, - environment.which.__name__, - return_value="C:/Windows/PowerShell.exe", - ), patch.object( - environment.process_adapter, - environment.process_adapter.run.__name__, - return_value=completed, - ) as run: + ), + patch.object( + registry_adapter, + registry_adapter.OpenKey.__name__, + return_value=key, + ), + patch.object( + registry_adapter, + registry_adapter.QueryValueEx.__name__, + return_value=(1, 4), + ), + patch.object( + environment, + environment.which.__name__, + return_value="C:/Windows/PowerShell.exe", + ), + patch.object( + environment.process_adapter, + environment.process_adapter.run.__name__, + return_value=completed, + ) as run, + ): report = inspect_environment() self.assertTrue(report.is_administrator) self.assertTrue(report.uac_enabled) @@ -297,10 +320,7 @@ def test_core_collectors_cannot_use_ctypes_directly(self) -> None: offenders = [ str(path.relative_to(project_root)) for path in (project_root / "core").rglob("*.py") - if any( - token in path.read_text(encoding="utf-8") - for token in forbidden_tokens - ) + if any(token in path.read_text(encoding="utf-8") for token in forbidden_tokens) ] self.assertEqual([], offenders) @@ -340,8 +360,15 @@ def test_filesystem_adapter_enumerates_and_copies_without_publishing(self) -> No def test_core_collectors_use_the_filesystem_boundary_for_host_traversal(self) -> None: project_root = Path(__file__).resolve().parent.parent - forbidden = ("Path.home()", "os.walk(", "os.scandir(", "shutil.copy2(", "shutil.disk_usage(", - ".rglob(", ".iterdir(") + forbidden = ( + "Path.home()", + "os.walk(", + "os.scandir(", + "shutil.copy2(", + "shutil.disk_usage(", + ".rglob(", + ".iterdir(", + ) offenders = [] for path in (project_root / "core").rglob("*.py"): source = path.read_text(encoding="utf-8") diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 66561082..45e75eb1 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -1,31 +1,30 @@ from __future__ import annotations import io -import json import subprocess import tempfile import unittest from pathlib import Path from unittest.mock import patch +from logicytics.cli import CLI, main +from logicytics.contracts import ( + Capability, + RunRequest, +) from logicytics.module import ( discovery, ) -from logicytics.cli import CLI, main from logicytics.module.configuration import ( load_config, ) -from logicytics.contracts import ( - Capability, - RunRequest, -) from logicytics.module.discovery import preflight from logicytics.module.environment import EnvironmentReport from logicytics.module.errors import PlanError, PreflightError from logicytics.module.planner import build_plan -from logicytics.platform_adapters import ProcessAdapter from logicytics.module.runtime import RunSupervisor -from tests.fixtures.collectors import COLLECTOR, plugin_collector_source, delayed_collector_source +from logicytics.platform_adapters import ProcessAdapter +from tests.fixtures.collectors import COLLECTOR, delayed_collector_source, plugin_collector_source class PreflightTests(unittest.TestCase): @@ -41,8 +40,7 @@ def test_preflight_allows_global_ctypes_collector_infrastructure(self) -> None: collector_path.write_text( COLLECTOR.replace( "from pathlib import Path\n", - "from pathlib import Path\n" - "from logicytics import ctypes_collector\n", + "from pathlib import Path\nfrom logicytics import ctypes_collector\n", ), encoding="utf-8", ) @@ -91,7 +89,7 @@ def test_plugin_preflight_requires_explicit_security_cost_and_output_metadata(se ) def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity( - self, + self, ) -> None: """Only an exact validation context may reuse an isolated runtime probe.""" with tempfile.TemporaryDirectory() as temporary: @@ -104,18 +102,18 @@ def test_preflight_cache_requires_source_interpreter_contract_and_configuration_ self.assertEqual(1, len(initial.valid), initial.invalid) with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, ) as probe: cached = preflight(root, configuration_hash="configuration-a") self.assertEqual(1, len(cached.valid), cached.invalid) probe.assert_not_called() with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, ) as probe: invalidated = preflight(root, configuration_hash="configuration-b") self.assertEqual(1, len(invalidated.valid), invalidated.invalid) @@ -187,8 +185,7 @@ def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: collector_path.write_text( COLLECTOR.replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' sensitive_data_categories=("credentials",),', + ' supported_platforms=("win32",),\n sensitive_data_categories=("credentials",),', ), encoding="utf-8", ) @@ -223,11 +220,13 @@ def test_run_output_budget_bounds_concurrent_collector_evidence(self) -> None: outcome = RunSupervisor(root, configuration).run(plan) records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) - self.assertEqual("failed", records["core.system.b_second"].status) - self.assertTrue( - any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors) + self.assertEqual( + "succeeded", + records["core.system.a_first"].status, + records["core.system.a_first"].errors, ) + self.assertEqual("failed", records["core.system.b_second"].status) + self.assertTrue(any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors)) self.assertLessEqual(outcome.manifest.total_artifact_bytes, 7) def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: @@ -253,7 +252,11 @@ def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: outcome = RunSupervisor(root, configuration).run(plan) records = {record.id: record for record in outcome.manifest.collectors} - self.assertEqual("succeeded", records["core.system.a_first"].status, records["core.system.a_first"].errors) + self.assertEqual( + "succeeded", + records["core.system.a_first"].status, + records["core.system.a_first"].errors, + ) second = records["core.system.b_second"] self.assertEqual("skipped", second.status) self.assertIsNone(second.started_at) @@ -411,16 +414,22 @@ def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(se ) for arguments, expected_exit in ( - (["preflight"], 0), - (["preflight", "--include", "plugin.broken_plugin"], 2), - (["preflight", "--plugins"], 2), + (["preflight"], 0), + (["preflight", "--include", "plugin.broken_plugin"], 2), + (["preflight", "--plugins"], 2), ): with self.subTest(arguments=arguments): output = io.StringIO() - with patch.object(CLI, "project_root", return_value=root), patch( - "sys.stdout", output, - ), patch( - "sys.stderr", output, + with ( + patch.object(CLI, "project_root", return_value=root), + patch( + "sys.stdout", + output, + ), + patch( + "sys.stderr", + output, + ), ): exit_code = main(arguments) rendered = output.getvalue() @@ -437,20 +446,20 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text( - COLLECTOR.replace("def collect(self, context: CollectorContext) -> CollectorResult:", - "def collect(self, context: CollectorContext) -> ValidationResult:"), + COLLECTOR.replace( + "def collect(self, context: CollectorContext) -> CollectorResult:", + "def collect(self, context: CollectorContext) -> ValidationResult:", + ), encoding="utf-8", ) report = preflight(root) self.assertEqual(1, len(report.invalid)) self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) - diagnostic = next( - item for item in report.invalid[0].diagnostics - if item.message == "collect must return CollectorResult" - ) + diagnostic = next(item for item in report.invalid[0].diagnostics if item.message == "collect must return CollectorResult") source_lines = collector_path.read_text(encoding="utf-8").splitlines() expected_line = next( - index for index, line in enumerate(source_lines, start=1) + index + for index, line in enumerate(source_lines, start=1) if "def collect(self, context: CollectorContext) -> ValidationResult:" in line ) self.assertEqual(expected_line, diagnostic.line) @@ -468,7 +477,7 @@ def test_preflight_rejects_wrong_collection_estimate_type(self) -> None: COLLECTOR.replace( " def cleanup(self, context: CollectorContext) -> None:", " def estimate(self, context: CollectorContext) -> ValidationResult:\n" - " \"\"\"Return an invalid estimate type for this test.\"\"\"\n" + ' """Return an invalid estimate type for this test."""\n' " return ValidationResult(valid=True)\n\n" " def cleanup(self, context: CollectorContext) -> None:", ), @@ -489,7 +498,7 @@ def test_preflight_rejects_invalid_dependencies_contract(self) -> None: COLLECTOR.replace( " def cleanup(self, context: CollectorContext) -> None:", " def dependencies(self) -> tuple[str, ...]:\n" - " \"\"\"Return an invalid instance-level dependency declaration.\"\"\"\n" + ' """Return an invalid instance-level dependency declaration."""\n' " return ()\n\n" " def cleanup(self, context: CollectorContext) -> None:", ), @@ -511,7 +520,7 @@ def test_preflight_rejects_dependency_declaration_mismatch(self) -> None: " def cleanup(self, context: CollectorContext) -> None:", " @classmethod\n" " def dependencies(cls) -> tuple[str, ...]:\n" - " \"\"\"Return a dependency absent from metadata for this test.\"\"\"\n" + ' """Return a dependency absent from metadata for this test."""\n' " return ('core.system.missing',)\n\n" " def cleanup(self, context: CollectorContext) -> None:", ), @@ -519,7 +528,10 @@ def test_preflight_rejects_dependency_declaration_mismatch(self) -> None: ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("dependencies() must match metadata.dependencies", report.invalid[0].runtime_error or "") + self.assertIn( + "dependencies() must match metadata.dependencies", + report.invalid[0].runtime_error or "", + ) def test_preflight_rejects_malformed_validate_result(self) -> None: """The isolated preflight probe must reject a non-ValidationResult response.""" @@ -536,7 +548,9 @@ def test_preflight_rejects_malformed_validate_result(self) -> None: self.assertEqual(1, len(report.invalid)) self.assertIn("validate() must return ValidationResult", report.invalid[0].runtime_error or "") - def test_preflight_rejects_validation_filesystem_process_network_and_environment_side_effects(self) -> None: + def test_preflight_rejects_validation_filesystem_process_network_and_environment_side_effects( + self, + ) -> None: """No validation probe may mutate files, launch processes, open sockets, or alter its environment.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -566,7 +580,10 @@ def test_preflight_rejects_validation_filesystem_process_network_and_environment ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("validate() must not perform side effects", report.invalid[0].runtime_error or "") + self.assertIn( + "validate() must not perform side effects", + report.invalid[0].runtime_error or "", + ) self.assertIn(event, report.invalid[0].runtime_error or "") self.assertFalse(outside.exists()) @@ -578,8 +595,14 @@ def test_preflight_rejects_validation_artifact_registration_and_exceptions(self) collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() attempts = ( - ("context.artifacts.register_file(context.workspace / 'evidence.txt')", "must not register artifacts"), - ("raise RuntimeError('unexpected validation failure')", "unexpected validation failure"), + ( + "context.artifacts.register_file(context.workspace / 'evidence.txt')", + "must not register artifacts", + ), + ( + "raise RuntimeError('unexpected validation failure')", + "unexpected validation failure", + ), ) for statement, message in attempts: with self.subTest(message=message): @@ -603,12 +626,12 @@ def test_preflight_rejects_a_hanging_validation_worker(self) -> None: (root / "plugins").mkdir() collector_path.write_text(COLLECTOR, encoding="utf-8") with patch.object( - discovery.process_adapter, - discovery.process_adapter.run.__name__, - side_effect=subprocess.TimeoutExpired( - ["validation-worker"], - timeout=10, - ), + discovery.process_adapter, + discovery.process_adapter.run.__name__, + side_effect=subprocess.TimeoutExpired( + ["validation-worker"], + timeout=10, + ), ): report = preflight(root) self.assertEqual(1, len(report.invalid)) @@ -623,14 +646,18 @@ def test_preflight_rejects_collector_print_calls(self) -> None: collector_path.parent.mkdir(parents=True) (root / "plugins").mkdir() collector_path.write_text( - COLLECTOR.replace("return CollectorResult.succeeded(\"test artifact created\", (artifact,))", - "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))"), + COLLECTOR.replace( + 'return CollectorResult.succeeded("test artifact created", (artifact,))', + "print('unexpected output')\n return CollectorResult.succeeded(\"test artifact created\", (artifact,))", + ), encoding="utf-8", ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("collectors must not print; use structured progress or logging", - report.invalid[0].static_errors) + self.assertIn( + "collectors must not print; use structured progress or logging", + report.invalid[0].static_errors, + ) def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> None: """Collection-like work must not run before a worker has isolated the collector.""" @@ -643,10 +670,12 @@ def test_preflight_rejects_import_time_calls_in_headers_and_class_body(self) -> COLLECTOR.replace( "class SystemInfoCollector(CoreCollector):", "class SystemInfoCollector(CoreCollector, type(open('unexpected.txt'))):", - ).replace( + ) + .replace( " @classmethod\n def metadata", " marker = input('unexpected prompt')\n\n @classmethod\n def metadata", - ).replace( + ) + .replace( "def validate(self, context: CollectorContext) -> ValidationResult:", "def validate(self, context: CollectorContext = open('another.txt')) -> ValidationResult:", ), @@ -692,15 +721,19 @@ def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> Non source = plugin_collector_source() source = source.replace("SystemInfoCollector", "AdminPluginCollector") source = source.replace("core.system.system_info", "plugin.admin_plugin") - source = source.replace( - "from logicytics import CollectorMetadata", - "from logicytics import Capability, CollectorMetadata", - ).replace( - " capabilities=(),", - " capabilities=(Capability.ELEVATED_PRIVILEGES,),", - ).replace( - " privilege_level=PrivilegeLevel.STANDARD,", - " privilege_level=PrivilegeLevel.ELEVATED,", + source = ( + source.replace( + "from logicytics import CollectorMetadata", + "from logicytics import Capability, CollectorMetadata", + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.ELEVATED_PRIVILEGES,),", + ) + .replace( + " privilege_level=PrivilegeLevel.STANDARD,", + " privilege_level=PrivilegeLevel.ELEVATED,", + ) ) plugin_path.write_text(source, encoding="utf-8") report = preflight(root) @@ -713,12 +746,14 @@ def test_unselected_privileged_plugin_never_requests_host_elevation(self) -> Non enable_plugins=True, approved_capabilities=(Capability.ELEVATED_PRIVILEGES,), ) - with patch( + with ( + patch( "logicytics.module.planner.inspect_environment", return_value=EnvironmentReport(False, True, None), + ), + self.assertRaisesRegex(PlanError, "administrator account"), ): - with self.assertRaisesRegex(PlanError, "administrator account"): - build_plan(report, enabled) + build_plan(report, enabled) if __name__ == "__main__": diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 0b7cc14d..1bd3e297 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -6,9 +6,9 @@ import sys import tempfile import unittest +from collections.abc import Iterable from dataclasses import FrozenInstanceError from pathlib import Path -from typing import Iterable from unittest.mock import MagicMock, patch from logicytics import ( @@ -186,8 +186,10 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> self.assertEqual(RunStatus.SUCCEEDED, snapshot.status, outcome.manifest.package) self.assertEqual(outcome.run_directory, snapshot.run_directory) self.assertEqual(outcome.manifest_path, snapshot.manifest_path) - self.assertEqual(1, - json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"]) + self.assertEqual( + 1, + json.loads(outcome.manifest_path.read_text(encoding="utf-8"))["manifest_schema_version"], + ) self.assertEqual(["core.system.system_info"], [item.collector_id for item in snapshot.collectors]) self.assertEqual(["succeeded"], [item.status for item in snapshot.collectors]) collector = snapshot.collectors[0] @@ -206,12 +208,12 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> read_artifact(root, snapshot.run_id, snapshot.artifacts[0].id, configuration=configuration), ) with self.assertRaises(FrozenInstanceError): - setattr(snapshot, "status", RunStatus.FAILED) + snapshot.status = RunStatus.FAILED with self.assertRaises(FrozenInstanceError): - setattr(collector, "status", "failed") + collector.status = "failed" def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration( - self, + self, ) -> None: """Callers can inspect redacted lifecycle timing and actionable failure details without parsing manifests.""" with tempfile.TemporaryDirectory() as temporary: @@ -223,8 +225,7 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat collector_path.write_text( COLLECTOR.replace( ' output = context.workspace / "system.txt"', - ' raise RuntimeError("fixture lifecycle failure")\n' - ' output = context.workspace / "system.txt"', + ' raise RuntimeError("fixture lifecycle failure")\n output = context.workspace / "system.txt"', ), encoding="utf-8", ) @@ -249,12 +250,7 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat self.assertIsNotNone(collector.summary) assert collector.summary is not None self.assertIn("worker crashed", collector.summary) - self.assertTrue( - any( - "fixture lifecycle failure" in error - for error in collector.errors - ) - ) + self.assertTrue(any("fixture lifecycle failure" in error for error in collector.errors)) failure = collector.failure if failure is None: self.fail("failed collector must expose structured failure details") @@ -271,17 +267,21 @@ def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_durat console_logger = MagicMock() - with patch.object( + with ( + patch.object( CLI, "project_root", return_value=root, - ), patch( - "logicytics.cli.commands.get_application_logger", - return_value=console_logger, - ), patch( - "builtins.input", - return_value="", - ) as final_prompt: + ), + patch( + "logicytics.cli.commands.get_application_logger", + return_value=console_logger, + ), + patch( + "builtins.input", + return_value="", + ) as final_prompt, + ): exit_code = main( [ "run", @@ -392,7 +392,9 @@ def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links( with self.assertRaisesRegex(PlanError, "escapes its run-owned directory"): query_run(root, outcome.manifest.run_id) - def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_access(self) -> None: + def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_access( + self, + ) -> None: """Only exact, bounded, manifest-cataloged bytes from the producing collector may be read.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py index aa6249b3..2fd55d2c 100644 --- a/tests/test_release_documentation.py +++ b/tests/test_release_documentation.py @@ -2,7 +2,6 @@ from __future__ import annotations -import re import tomllib import unittest from pathlib import Path @@ -25,10 +24,10 @@ def test_release_identity_is_final_and_no_provisional_claim_remains(self) -> Non self.assertIn("complete recreation", self.release.casefold()) self.assertIn("verified run", self.readme.casefold()) for provisional in ( - "being rebuilt", - "early v4 implementation", - "broader collection catalog is being rebuilt", - "to be released", + "being rebuilt", + "early v4 implementation", + "broader collection catalog is being rebuilt", + "to be released", ): with self.subTest(provisional=provisional): self.assertNotIn(provisional, self.readme.casefold()) @@ -49,10 +48,7 @@ def test_release_covers_every_specialty_mode_and_output_media_type(self) -> None with self.subTest(mode=mode): self.assertIn(f"`{mode}`", self.release) media_types = { - media_type - for candidate in core - if candidate.metadata is not None - for media_type in candidate.metadata.output_media_types + media_type for candidate in core if candidate.metadata is not None for media_type in candidate.metadata.output_media_types } for media_type in media_types: with self.subTest(media_type=media_type): @@ -64,22 +60,41 @@ def test_release_evidence_is_recorded(self) -> None: def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> None: documents = ( - "CONFIGURATION.md", "CONTRIBUTING.md", "OUTPUTS.md", "MODS.md", - "MIGRATION.md", "FLOW_MATRIX.md", "V4_RELEASE.md", - "SECURITY.md", "CODE_OF_CONDUCT.md", + "CONFIGURATION.md", + "CONTRIBUTING.md", + "OUTPUTS.md", + "MODS.md", + "MIGRATION.md", + "FLOW_MATRIX.md", + "V4_RELEASE.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", ) for document in documents: with self.subTest(document=document): - location = self.root / "docs" / document if document.endswith(".md") and document not in { - "CONTRIBUTING.md", "SECURITY.md", "CODE_OF_CONDUCT.md", - } else self.root / document + location = ( + self.root / "docs" / document + if document.endswith(".md") + and document + not in { + "CONTRIBUTING.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + } + else self.root / document + ) self.assertTrue(location.is_file()) self.assertIn("Logicytics Wiki", self.readme) contributor = (self.root / "CONTRIBUTING.md").read_text(encoding="utf-8") for requirement in ( - "Development setup", "Architecture boundaries", "Core collector changes", - "Plugins and MODs", "Configuration changes", "Testing expectations", - "conventional commit", "Developer Certificate of Origin", + "Development setup", + "Architecture boundaries", + "Core collector changes", + "Plugins and MODs", + "Configuration changes", + "Testing expectations", + "conventional commit", + "Developer Certificate of Origin", ): with self.subTest(requirement=requirement): self.assertIn(requirement, contributor) diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index 76d8d083..c1fdafd8 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -16,12 +16,12 @@ class _UnusedArtifactWriter(ArtifactWriter): """Raise if a skipped collector unexpectedly attempts artifact registration.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - transformations: tuple[str, ...] = (), - evidence_kind: str | None = None, + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + evidence_kind: str | None = None, ): raise AssertionError("a skipped collector must not register artifacts") @@ -29,7 +29,7 @@ def register_file( class _NoopLogger(EventLogger): """Discard events while directly exercising a collector method.""" - def event(self, level: str, message: str, **fields: int | float | str) -> None: + def event(self, level: str, message: str, **fields: float | str) -> None: return None @@ -61,9 +61,9 @@ def test_access_denied_is_skipped_not_failed(self) -> None: cancellation_file=workspace / ".cancelled", ) with patch.object( - module.subprocess, - module.subprocess.run.__name__, - return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), + module.subprocess, + module.subprocess.run.__name__, + return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), ): result = collector_type().collect(context) self.assertEqual(CollectorStatus.SKIPPED, result.status) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 902ec02e..d44a8cb2 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -8,19 +8,19 @@ from pathlib import Path from unittest.mock import patch -from logicytics.module.configuration import ( - default_config, -) -from logicytics.contracts import ( - Capability, - RunRequest, -) from logicytics import ( STILL_ACTIVE, close_handle, get_exit_code_process, open_process, ) +from logicytics.contracts import ( + Capability, + RunRequest, +) +from logicytics.module.configuration import ( + default_config, +) from logicytics.module.discovery import preflight from logicytics.module.planner import build_plan from logicytics.module.runtime import RunSupervisor @@ -74,10 +74,7 @@ def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: default_config(root), ).run(plan) - records = { - record.id: record - for record in outcome.manifest.collectors - } + records = {record.id: record for record in outcome.manifest.collectors} self.assertEqual( "failed", @@ -91,12 +88,7 @@ def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: dependent.status, ) self.assertIsNone(dependent.started_at) - self.assertTrue( - any( - dependency_id in error - for error in dependent.errors - ) - ) + self.assertTrue(any(dependency_id in error for error in dependent.errors)) self.assertEqual( ["core.system.b_dependent"], @@ -108,22 +100,15 @@ def test_failed_dependency_skips_dependent_without_launching_it(self) -> None: dependency_id, "core.system.b_dependent", }, - { - error["collector_id"] - for error in outcome.manifest.errors - }, + {error["collector_id"] for error in outcome.manifest.errors}, ) package = outcome.manifest.package assert package is not None with zipfile.ZipFile(Path(package["path"])) as archive: - packaged = json.loads( - archive.read("metadata/manifest.json") - ) - summary = archive.read( - "reports/summary.txt" - ).decode("utf-8") + packaged = json.loads(archive.read("metadata/manifest.json")) + summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual( ["core.system.b_dependent"], @@ -189,13 +174,11 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N collector_path.write_text( COLLECTOR.replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' maximum_retries=1,\n' - ' retry_delay_seconds=0.01,', + ' supported_platforms=("win32",),\n maximum_retries=1,\n retry_delay_seconds=0.01,', ).replace( ' output = context.workspace / "system.txt"', ' marker = context.workspace / "attempt.marker"\n' - ' if not marker.exists():\n' + " if not marker.exists():\n" ' marker.write_text("first attempt failed", encoding="utf-8")\n' ' raise RuntimeError("temporary collector failure")\n' ' output = context.workspace / "system.txt"', @@ -214,9 +197,7 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N self.assertEqual(1, len(record.retry_history)) self.assertEqual("collect", record.retry_history[0]["failure"]["operation"]) self.assertTrue(record.retry_history[0]["failure"]["retry_safe"]) - self.assertTrue( - any("temporary collector failure" in error for error in record.retry_history[0]["errors"]) - ) + self.assertTrue(any("temporary collector failure" in error for error in record.retry_history[0]["errors"])) self.assertIsInstance(record.worker_pid, int) self.assertIsInstance(record.retry_history[0]["worker_pid"], int) self.assertNotEqual(record.worker_pid, record.retry_history[0]["worker_pid"]) @@ -226,9 +207,7 @@ def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> N assert package is not None with zipfile.ZipFile(Path(package["path"])) as archive: - packaged_record = json.loads( - archive.read("metadata/manifest.json") - )["collectors"][0] + packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(2, packaged_record["attempt_count"]) self.assertEqual(1, len(packaged_record["retry_history"])) @@ -313,30 +292,34 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non "evidence/derived/core_system_system_info/system.txt", archive.namelist(), ) - packaged = json.loads( - archive.read("metadata/manifest.json") - )["collectors"][0] + packaged = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] summary = archive.read("reports/summary.txt").decode("utf-8") self.assertEqual(record.failure, packaged["failure"]) self.assertIn("Failed operation: collect", summary) self.assertIn("Retry safe: false", summary) self.assertIn("Remediation:", summary) - def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results(self) -> None: + def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results( + self, + ) -> None: """A failed process finalizes once, preserves evidence, and cannot stop its neighbor.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) core_directory = root / "core" / "system" core_directory.mkdir(parents=True) (root / "plugins").mkdir() - failing_source = delayed_collector_source("a_failed", 0.0).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raise RuntimeError("collection failed after evidence registration")', - ).replace( - ' """Release test resources."""', - ' """Release test resources."""\n' - ' (context.workspace / "cleanup.marker").write_text("cleaned", encoding="utf-8")\n' - ' (context.temporary_directory / "scratch.txt").write_text("scratch", encoding="utf-8")', + failing_source = ( + delayed_collector_source("a_failed", 0.0) + .replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("collection failed after evidence registration")', + ) + .replace( + ' """Release test resources."""', + ' """Release test resources."""\n' + ' (context.workspace / "cleanup.marker").write_text("cleaned", encoding="utf-8")\n' + ' (context.temporary_directory / "scratch.txt").write_text("scratch", encoding="utf-8")', + ) ) (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") (core_directory / "z_independent.py").write_text( @@ -368,12 +351,7 @@ def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolat assert package is not None with zipfile.ZipFile(Path(package["path"])) as archive: - packaged = { - item["id"]: item - for item in json.loads( - archive.read("metadata/manifest.json") - )["collectors"] - } + packaged = {item["id"]: item for item in json.loads(archive.read("metadata/manifest.json"))["collectors"]} summary = archive.read("reports/summary.txt").decode("utf-8") self.assertIn( "evidence/derived/core_system_a_failed/system.txt", @@ -387,20 +365,25 @@ def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolat self.assertIn("Termination: failed", summary) self.assertIn("collection failed after evidence registration", summary) - def test_cleanup_failure_preserves_original_collector_crash_and_independent_results(self) -> None: + def test_cleanup_failure_preserves_original_collector_crash_and_independent_results( + self, + ) -> None: """Cleanup errors must be reported without replacing the original failure.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) core_directory = root / "core" / "system" core_directory.mkdir(parents=True) (root / "plugins").mkdir() - failing_source = delayed_collector_source("a_failed", 0.0).replace( - ' return CollectorResult.succeeded("test artifact created", (artifact,))', - ' raise RuntimeError("original collection failure")', - ).replace( - ' """Release test resources."""', - ' """Release test resources."""\n' - ' raise ValueError("secondary cleanup failure")', + failing_source = ( + delayed_collector_source("a_failed", 0.0) + .replace( + ' return CollectorResult.succeeded("test artifact created", (artifact,))', + ' raise RuntimeError("original collection failure")', + ) + .replace( + ' """Release test resources."""', + ' """Release test resources."""\n raise ValueError("secondary cleanup failure")', + ) ) (core_directory / "a_failed.py").write_text(failing_source, encoding="utf-8") (core_directory / "z_independent.py").write_text( @@ -429,8 +412,7 @@ def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> No collector_path.write_text( COLLECTOR.replace( ' """Release test resources."""', - ' """Release test resources."""\n' - ' raise RuntimeError("finalizer failed after collecting evidence")', + ' """Release test resources."""\n raise RuntimeError("finalizer failed after collecting evidence")', ), encoding="utf-8", ) @@ -465,17 +447,13 @@ def test_cleanup_failure_after_success_preserves_registered_evidence(self) -> No remediation = failure["remediation"] if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) + self.fail(f"remediation must be str, got {type(remediation).__name__}") self.assertIn("cleanup", remediation) retry_safe = failure["retry_safe"] if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) + self.fail(f"retry_safe must be bool, got {type(retry_safe).__name__}") self.assertFalse(retry_safe) @@ -499,8 +477,7 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: timeout_source = delayed_collector_source("a_timeout", 2.0).replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' timeout_seconds=1,', + ' supported_platforms=("win32",),\n timeout_seconds=1,', ) (core_directory / "a_timeout.py").write_text( @@ -529,17 +506,12 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: default_config(root), ).run(plan) - records = { - record.id: record - for record in outcome.manifest.collectors - } + records = {record.id: record for record in outcome.manifest.collectors} timeout_record = records["core.system.a_timeout"] self.assertEqual("failed", timeout_record.status) - self.assertTrue( - any("timeout" in error for error in timeout_record.errors) - ) + self.assertTrue(any("timeout" in error for error in timeout_record.errors)) self.assertEqual( "timeout_exceeded", timeout_record.termination_reason, @@ -550,17 +522,13 @@ def test_collector_timeout_preserves_independent_worker_results(self) -> None: retry_safe = timeout_failure["retry_safe"] if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) + self.fail(f"retry_safe must be bool, got {type(retry_safe).__name__}") self.assertTrue(retry_safe) remediation = timeout_failure["remediation"] if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) + self.fail(f"remediation must be str, got {type(remediation).__name__}") self.assertIn("timeout", remediation) @@ -578,18 +546,23 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer core_directory = root / "core" / "system" core_directory.mkdir(parents=True) (root / "plugins").mkdir() - source = delayed_collector_source("a_tree", 0.0).replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.SUBPROCESS,),\n timeout_seconds=2,', - ).replace( - ' output = context.workspace / "system.txt"', - ' child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])\n' - ' (context.workspace / "child.pid").write_text(str(child.pid), encoding="ascii")\n' - ' sleep(5)\n' - ' output = context.workspace / "system.txt"', + source = ( + delayed_collector_source("a_tree", 0.0) + .replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.SUBPROCESS,),\n timeout_seconds=2,", + ) + .replace( + ' output = context.workspace / "system.txt"', + ' child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"])\n' + ' (context.workspace / "child.pid").write_text(str(child.pid), encoding="ascii")\n' + " sleep(5)\n" + ' output = context.workspace / "system.txt"', + ) ) (core_directory / "a_tree.py").write_text(source, encoding="utf-8") (core_directory / "z_independent.py").write_text( @@ -608,11 +581,7 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer ) outcome = RunSupervisor(root, default_config(root)).run(plan) records = {record.id: record for record in outcome.manifest.collectors} - child_pid = int( - (outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text( - encoding="ascii" - ) - ) + child_pid = int((outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text(encoding="ascii")) handle = open_process(child_pid) if handle is not None: @@ -642,8 +611,7 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No limited_source = delayed_collector_source("a_limited", 2.0).replace( ' supported_platforms=("win32",),', - ' supported_platforms=("win32",),\n' - ' maximum_memory_bytes=1,', + ' supported_platforms=("win32",),\n maximum_memory_bytes=1,', ) (core_directory / "a_limited.py").write_text( @@ -672,10 +640,7 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No default_config(root), ).run(plan) - records = { - record.id: record - for record in outcome.manifest.collectors - } + records = {record.id: record for record in outcome.manifest.collectors} limited = records["core.system.a_limited"] @@ -685,12 +650,7 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No assert peak_memory_bytes is not None self.assertGreater(peak_memory_bytes, 1) - self.assertTrue( - any( - "maximum_memory_bytes=1" in error - for error in limited.errors - ) - ) + self.assertTrue(any("maximum_memory_bytes=1" in error for error in limited.errors)) self.assertEqual( "memory_limit_exceeded", limited.termination_reason, @@ -706,9 +666,7 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No remediation = limited_failure["remediation"] if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) + self.fail(f"remediation must be str, got {type(remediation).__name__}") self.assertIn( "memory limit", @@ -717,9 +675,7 @@ def test_collector_memory_limit_preserves_independent_worker_results(self) -> No retry_safe = limited_failure["retry_safe"] if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) + self.fail(f"retry_safe must be bool, got {type(retry_safe).__name__}") self.assertTrue(retry_safe) @@ -757,9 +713,9 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: ) with patch.object( - supervisor, - supervisor._supervise.__name__, - side_effect=KeyboardInterrupt, + supervisor, + supervisor._supervise.__name__, + side_effect=KeyboardInterrupt, ): outcome = supervisor.run(plan) @@ -781,13 +737,9 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: self.assertTrue(package_path.is_file()) with zipfile.ZipFile(package_path) as archive: - summary = archive.read( - "reports/summary.txt" - ).decode("utf-8") + summary = archive.read("reports/summary.txt").decode("utf-8") - packaged_manifest = json.loads( - archive.read("metadata/manifest.json") - ) + packaged_manifest = json.loads(archive.read("metadata/manifest.json")) self.assertTrue(packaged_manifest["cancellation_requested"]) self.assertIn("Status: cancelled", summary) @@ -813,14 +765,14 @@ def test_worker_runs_typed_prepare_collect_finalize_and_cleanup_lifecycle(self) source = COLLECTOR.replace( " def collect(self, context: CollectorContext) -> CollectorResult:", " def prepare(self, context: CollectorContext) -> ValidationResult:\n" - " \"\"\"Prepare worker-local lifecycle state.\"\"\"\n" + ' """Prepare worker-local lifecycle state."""\n' " context.settings['prepared'] = 'yes'\n" " return ValidationResult(True)\n\n" " def collect(self, context: CollectorContext) -> CollectorResult:", ).replace( " def cleanup(self, context: CollectorContext) -> None:", " def finalize(self, context: CollectorContext, result: CollectorResult) -> CollectorResult:\n" - " \"\"\"Finalize the typed result before it crosses the worker boundary.\"\"\"\n" + ' """Finalize the typed result before it crosses the worker boundary."""\n' " if context.settings.get('prepared') != 'yes':\n" " raise RuntimeError('prepare phase did not run')\n" " return CollectorResult.succeeded('finalized lifecycle', result.artifacts)\n\n" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 149731f7..caf450fb 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -5,13 +5,13 @@ from pathlib import Path from logicytics.cli import cli_methods -from logicytics.module.configuration import ( - default_config, -) from logicytics.contracts import ( ResourceClass, RunRequest, ) +from logicytics.module.configuration import ( + default_config, +) from logicytics.module.discovery import preflight from logicytics.module.planner import build_plan from logicytics.module.runtime import RunSupervisor @@ -52,10 +52,7 @@ def test_parallel_completion_preserves_deterministic_manifest_order(self) -> Non planned_ids.append(candidate.metadata.id) outcome = RunSupervisor(root, default_config(root)).run(plan) - records = { - record.id: record - for record in outcome.manifest.collectors - } + records = {record.id: record for record in outcome.manifest.collectors} self.assertEqual( planned_ids, @@ -73,7 +70,10 @@ def test_parallel_completion_preserves_deterministic_manifest_order(self) -> Non def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: """First-class CLI policies determine real sequential versus bounded worker overlap.""" for execution_mode, expect_overlap in (("--sequential", False), ("--parallel", True)): - with self.subTest(execution_mode=execution_mode), tempfile.TemporaryDirectory() as temporary: + with ( + self.subTest(execution_mode=execution_mode), + tempfile.TemporaryDirectory() as temporary, + ): root = Path(temporary) core_directory = root / "core" / "system" core_directory.mkdir(parents=True) @@ -83,9 +83,7 @@ def test_explicit_execution_modes_control_isolated_worker_overlap(self) -> None: delayed_collector_source(filename, 0.3), encoding="utf-8", ) - arguments = cli_methods.parser().parse_args( - ["run", execution_mode, "--acknowledge-authorization"] - ) + arguments = cli_methods.parser().parse_args(["run", execution_mode, "--acknowledge-authorization"]) run_request = cli_methods.request(arguments, default_workers=2) report = preflight(root) self.assertEqual((), report.invalid) @@ -140,11 +138,14 @@ def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: """Disk, network, and registry resource conflicts each serialize their owners.""" for resource_class in ( - ResourceClass.DISK_HEAVY, - ResourceClass.NETWORK_HEAVY, - ResourceClass.REGISTRY_SENSITIVE, + ResourceClass.DISK_HEAVY, + ResourceClass.NETWORK_HEAVY, + ResourceClass.REGISTRY_SENSITIVE, ): - with self.subTest(resource_class=resource_class), tempfile.TemporaryDirectory() as temporary: + with ( + self.subTest(resource_class=resource_class), + tempfile.TemporaryDirectory() as temporary, + ): root = Path(temporary) core_directory = root / "core" / "system" core_directory.mkdir(parents=True) diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 364ec455..7f0fef9b 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -7,13 +7,13 @@ import subprocess import tempfile import unittest +from collections.abc import Callable from contextlib import ExitStack from pathlib import Path -from typing import Any, NoReturn, cast, Callable +from typing import Any, NoReturn, cast from unittest.mock import patch from logicytics import ResourceClass -from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import ( ArtifactWriter, CollectorContext, @@ -21,6 +21,7 @@ EventLogger, EvidenceKind, ) +from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.module.discovery import preflight from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.module.output_contracts import core_output_contract @@ -37,26 +38,24 @@ class _RejectingWriter(ArtifactWriter): """Prove cancellation paths never attempt to publish evidence.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> NoReturn: - raise AssertionError( - "a cancelled collector must not register an artifact" - ) + raise AssertionError("a cancelled collector must not register an artifact") class _NoopLogger(EventLogger): """Discard test events.""" def event( - self, - level: str, - message: str, - **fields: int | float | str, + self, + level: str, + message: str, + **fields: float | str, ) -> None: return None @@ -75,9 +74,7 @@ def _load_collector(path: Path, class_name: str) -> Any: collector_object: object = getattr(module, class_name, None) if not isinstance(collector_object, type): - raise TypeError( - f"{class_name} in {path} is missing or is not a class" - ) + raise TypeError(f"{class_name} in {path} is missing or is not a class") collector_factory = cast(Callable[[], Any], collector_object) return collector_factory() @@ -175,7 +172,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: ) def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling( - self, + self, ) -> None: """The independent event channels may overlap while retaining separate identities.""" project_root = Path(__file__).resolve().parent.parent @@ -214,7 +211,7 @@ def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling( ) def test_mode_matrix_represents_every_discovered_collector_exactly_once( - self, + self, ) -> None: """Mode documentation covers selected, manual-only, and quarantined collectors.""" project_root = Path(__file__).resolve().parent.parent @@ -233,10 +230,7 @@ def test_mode_matrix_represents_every_discovered_collector_exactly_once( else: expected_ids.add(candidate.selection_id) - represented_ids = [ - row["id"] - for row in collector_rows - ] + represented_ids = [row["id"] for row in collector_rows] self.assertEqual( expected_ids, @@ -247,10 +241,7 @@ def test_mode_matrix_represents_every_discovered_collector_exactly_once( len(set(represented_ids)), ) - modes = { - row["name"]: row - for row in matrix["modes"] - } + modes = {row["name"]: row for row in matrix["modes"]} self.assertEqual( set(EXECUTION_MODES), @@ -271,11 +262,7 @@ def test_mode_matrix_represents_every_discovered_collector_exactly_once( ) for mode_name, mode in modes.items(): - expected = { - row["id"] - for row in collector_rows - if mode_name in row["modes"] - } + expected = {row["id"] for row in collector_rows if mode_name in row["modes"]} self.assertEqual( expected, @@ -283,32 +270,23 @@ def test_mode_matrix_represents_every_discovered_collector_exactly_once( ) def test_every_core_collector_uses_context_artifacts_without_mutable_globals( - self, + self, ) -> None: """Migration is complete only when all core modules use owned workspaces and catalogs.""" project_root = Path(__file__).resolve().parent.parent report = preflight(project_root) - core_candidates = [ - candidate - for candidate in report.valid - if candidate.kind.value == "core" - ] + core_candidates = [candidate for candidate in report.valid if candidate.kind.value == "core"] self.assertTrue(core_candidates) for candidate in core_candidates: metadata = candidate.metadata if metadata is None: - self.fail( - f"valid core candidate {candidate.selection_id} " - "has no metadata" - ) + self.fail(f"valid core candidate {candidate.selection_id} has no metadata") with self.subTest(collector=metadata.id): - source = candidate.path.read_text( - encoding="utf-8" - ) + source = candidate.path.read_text(encoding="utf-8") tree = ast.parse( source, filename=str(candidate.path), @@ -329,35 +307,29 @@ def test_every_core_collector_uses_context_artifacts_without_mutable_globals( for node in ast.walk(tree): if ( - isinstance( - node, - ( - ast.FunctionDef, - ast.AsyncFunctionDef, - ), - ) - and node.name == "collect" + isinstance( + node, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ), + ) + and node.name == "collect" ): collect = node break if collect is None: - self.fail( - f"{metadata.id} does not define collect()" - ) + self.fail(f"{metadata.id} does not define collect()") - attributes = { - node.attr - for node in ast.walk(collect) - if isinstance(node, ast.Attribute) - } + attributes = {node.attr for node in ast.walk(collect) if isinstance(node, ast.Attribute)} self.assertIn("workspace", attributes) self.assertIn("artifacts", attributes) self.assertIn("register_file", attributes) def test_every_core_collector_has_a_stable_output_contract( - self, + self, ) -> None: """Every shipped output has explicit names, formats, package paths, and retention.""" project_root = Path(__file__).resolve().parent.parent @@ -369,10 +341,7 @@ def test_every_core_collector_has_a_stable_output_contract( metadata = candidate.metadata if metadata is None: - self.fail( - f"valid core candidate {candidate.selection_id} " - "has no metadata" - ) + self.fail(f"valid core candidate {candidate.selection_id} has no metadata") with self.subTest(collector=metadata.id): contract = core_output_contract(metadata) @@ -381,35 +350,20 @@ def test_every_core_collector_has_a_stable_output_contract( metadata.output_media_types, contract.media_types, ) - self.assertTrue( - contract.workspace_patterns - ) - self.assertTrue( - all( - "\\" not in pattern - and not pattern.startswith("/") - for pattern in contract.workspace_patterns - ) - ) + self.assertTrue(contract.workspace_patterns) + self.assertTrue(all("\\" not in pattern and not pattern.startswith("/") for pattern in contract.workspace_patterns)) self.assertEqual( len(contract.workspace_patterns), len(contract.package_patterns), ) - self.assertTrue( - all( - path.startswith( - "evidence/{kind}/core_" - ) - for path in contract.package_patterns - ) - ) + self.assertTrue(all(path.startswith("evidence/{kind}/core_") for path in contract.package_patterns)) self.assertEqual( "retained_with_run", contract.retention, ) def test_every_core_collector_obeys_the_typed_lifecycle_contract( - self, + self, ) -> None: """All shipped collectors fail closed on cancellation without platform access or artifacts.""" project_root = Path(__file__).resolve().parent.parent @@ -421,10 +375,7 @@ def test_every_core_collector_obeys_the_typed_lifecycle_contract( metadata = candidate.metadata if metadata is None: - self.fail( - f"valid core candidate {candidate.selection_id} " - "has no metadata" - ) + self.fail(f"valid core candidate {candidate.selection_id} has no metadata") with ( self.subTest(collector=metadata.id), @@ -455,9 +406,7 @@ def test_every_core_collector_obeys_the_typed_lifecycle_contract( self.assertTrue(validation.reasons) prepared = collector.prepare(context) - self.assertTrue( - hasattr(prepared, "valid") - ) + self.assertTrue(hasattr(prepared, "valid")) result = collector.collect(context) @@ -478,7 +427,7 @@ def test_every_core_collector_obeys_the_typed_lifecycle_contract( collector.cleanup(context) def test_every_registered_artifact_media_type_is_declared( - self, + self, ) -> None: """Static artifact calls cannot introduce an undeclared output format.""" project_root = Path(__file__).resolve().parent.parent @@ -490,15 +439,10 @@ def test_every_registered_artifact_media_type_is_declared( metadata = candidate.metadata if metadata is None: - self.fail( - f"valid core candidate {candidate.selection_id} " - "has no metadata" - ) + self.fail(f"valid core candidate {candidate.selection_id} has no metadata") tree = ast.parse( - candidate.path.read_text( - encoding="utf-8" - ), + candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path), ) @@ -509,11 +453,11 @@ def test_every_registered_artifact_media_type_is_declared( continue if ( - isinstance( - node.func, - ast.Attribute, - ) - and node.func.attr == "register_file" + isinstance( + node.func, + ast.Attribute, + ) + and node.func.attr == "register_file" ): register_calls.append(node) @@ -527,22 +471,12 @@ def test_every_registered_artifact_media_type_is_declared( value = keyword.value if isinstance(value, ast.Constant) and isinstance(value.value, str): - declared_at_calls.add( - value.value - ) + declared_at_calls.add(value.value) - has_implicit_media_type = any( - not any( - keyword.arg == "media_type" - for keyword in call.keywords - ) - for call in register_calls - ) + has_implicit_media_type = any(not any(keyword.arg == "media_type" for keyword in call.keywords) for call in register_calls) if has_implicit_media_type: - declared_at_calls.add( - "application/octet-stream" - ) + declared_at_calls.add("application/octet-stream") with self.subTest(collector=metadata.id): self.assertTrue(register_calls) @@ -553,7 +487,7 @@ def test_every_registered_artifact_media_type_is_declared( ) def test_migration_documentation_covers_every_supported_bridge( - self, + self, ) -> None: """Public compatibility stays explicit and canonical-output-only.""" project_root = Path(__file__).resolve().parent.parent @@ -572,9 +506,9 @@ def test_migration_documentation_covers_every_supported_bridge( ) for bridge in ( - "CODE/config.ini", - "core.integration.legacy_code_outputs", - "MODS/", + "CODE/config.ini", + "core.integration.legacy_code_outputs", + "MODS/", ): self.assertIn( bridge, @@ -593,22 +527,14 @@ def test_migration_documentation_covers_every_supported_bridge( if metadata is None: continue - if ( - metadata.id - == "core.integration.legacy_code_outputs" - ): + if metadata.id == "core.integration.legacy_code_outputs": legacy_metadata = metadata break if legacy_metadata is None: - self.fail( - "core.integration.legacy_code_outputs " - "was not discovered during preflight" - ) + self.fail("core.integration.legacy_code_outputs was not discovered during preflight") - contract = core_output_contract( - legacy_metadata - ) + contract = core_output_contract(legacy_metadata) self.assertEqual( ("legacy_code/**",), @@ -616,7 +542,7 @@ def test_migration_documentation_covers_every_supported_bridge( ) def test_each_core_module_owns_exactly_one_policy_contract( - self, + self, ) -> None: """Different permission, sensitivity, timeout, or output policies require separate IDs.""" project_root = Path(__file__).resolve().parent.parent @@ -633,53 +559,34 @@ def test_each_core_module_owns_exactly_one_policy_contract( metadata = candidate.metadata if metadata is None: - self.fail( - f"valid core candidate {candidate.selection_id} " - "has no metadata" - ) + self.fail(f"valid core candidate {candidate.selection_id} has no metadata") tree = ast.parse( - candidate.path.read_text( - encoding="utf-8" - ), + candidate.path.read_text(encoding="utf-8"), filename=str(candidate.path), ) - collector_classes: list[ - ast.ClassDef - ] = [] + collector_classes: list[ast.ClassDef] = [] for node in tree.body: if not isinstance( - node, - ast.ClassDef, + node, + ast.ClassDef, ): continue - inherits_core_collector = any( - ast.unparse(base).endswith( - "CoreCollector" - ) - for base in node.bases - ) + inherits_core_collector = any(ast.unparse(base).endswith("CoreCollector") for base in node.bases) if inherits_core_collector: - collector_classes.append( - node - ) + collector_classes.append(node) with self.subTest(collector=metadata.id): self.assertEqual( [candidate.expected_class], - [ - node.name - for node in collector_classes - ], + [node.name for node in collector_classes], ) - contract = core_output_contract( - metadata - ) + contract = core_output_contract(metadata) signatures[metadata.id] = ( metadata.capabilities, @@ -698,7 +605,7 @@ def test_each_core_module_owns_exactly_one_policy_contract( ) def test_every_core_collector_handles_mocked_windows_platform_responses( - self, + self, ) -> None: """Each collector returns a typed result when Windows adapters report unavailable data.""" project_root = Path(__file__).resolve().parent.parent @@ -727,9 +634,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( patch.object( registry_adapter, "OpenKey", - side_effect=OSError( - "missing key" - ), + side_effect=OSError("missing key"), ) ) @@ -737,9 +642,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( patch.object( windows_api_adapter, "load_library", - side_effect=OSError( - "API unavailable" - ), + side_effect=OSError("API unavailable"), ) ) @@ -779,9 +682,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( patch.object( network_adapter, "socket", - side_effect=PermissionError( - "denied" - ), + side_effect=PermissionError("denied"), ) ) @@ -815,24 +716,16 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( metadata = candidate.metadata if metadata is None: - self.fail( - f"valid core candidate " - f"{candidate.selection_id} " - "has no metadata" - ) + self.fail(f"valid core candidate {candidate.selection_id} has no metadata") - with self.subTest( - collector=metadata.id - ): + with self.subTest(collector=metadata.id): metadata = candidate.metadata self.assertIsNotNone(metadata) assert metadata is not None workspace = root / metadata.id.replace(".", "_") - artifact_root = ( - workspace / "published" - ) + artifact_root = workspace / "published" workspace.mkdir() artifact_root.mkdir() @@ -841,9 +734,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( run_id="run-" + "0" * 32, collector_id=metadata.id, workspace=workspace, - temporary_directory=( - workspace / "tmp" - ), + temporary_directory=(workspace / "tmp"), artifacts=WorkspaceArtifactWriter( metadata.id, workspace, @@ -853,10 +744,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( ), logger=_NoopLogger(), settings={}, - cancellation_file=( - workspace - / ".cancelled" - ), + cancellation_file=(workspace / ".cancelled"), ) collector = _load_collector( @@ -864,9 +752,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( candidate.expected_class, ) - result = collector.collect( - context - ) + result = collector.collect(context) self.assertIn( result.status, @@ -874,7 +760,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( ) def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( - self, + self, ) -> None: """Minimal and standard stay bounded while deep explicitly owns the exhaustive surface.""" project_root = Path(__file__).resolve().parent.parent @@ -892,11 +778,7 @@ def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( metadata.append(candidate_metadata) memberships = { - profile: { - item.id - for item in metadata - if profile in item.default_profiles - } + profile: {item.id for item in metadata if profile in item.default_profiles} for profile in ( "minimal", "standard", @@ -914,10 +796,7 @@ def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( len(memberships["standard"]), ) self.assertEqual( - { - item.id - for item in metadata - }, + {item.id for item in metadata}, memberships["deep"], ) self.assertLess( @@ -933,22 +812,10 @@ def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( memberships["offline"], ) self.assertLessEqual( - sum( - item.timeout_seconds - for item in metadata - if item.id - in memberships["standard"] - ), + sum(item.timeout_seconds for item in metadata if item.id in memberships["standard"]), 90, ) - self.assertTrue( - all( - not item.sensitive_data_categories - for item in metadata - if item.id - in memberships["standard"] - ) - ) + self.assertTrue(all(not item.sensitive_data_categories for item in metadata if item.id in memberships["standard"])) if __name__ == "__main__": diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 1afdcf7e..09cfe702 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -25,19 +25,24 @@ def test_windows_clear_uses_the_real_console_command(self) -> None: """Windows startup clears the shared screen buffer through cls, not escape bytes.""" output = _InteractiveBuffer() errors = _InteractiveBuffer() - with patch.object(terminal.sys, "stdout", output), patch.object( + with ( + patch.object(terminal.sys, "stdout", output), + patch.object( terminal.sys, "stderr", errors, - ), patch.object( + ), + patch.object( terminal.os, "name", "nt", - ), patch.object( + ), + patch.object( terminal.os, "system", return_value=0, - ) as system: + ) as system, + ): terminal._clear_terminal() system.assert_called_once_with("cls") @@ -60,11 +65,15 @@ def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None output = _InteractiveBuffer() errors = _InteractiveBuffer() - with patch.object(terminal.sys, "stdout", output), patch.object( + with ( + patch.object(terminal.sys, "stdout", output), + patch.object( terminal.sys, "stderr", errors, - ), patch.object(terminal, "_clear_terminal") as clear_terminal: + ), + patch.object(terminal, "_clear_terminal") as clear_terminal, + ): with terminal.terminal_lifecycle(): with terminal.terminal_lifecycle(): output.write("command output") @@ -77,11 +86,15 @@ def test_noninteractive_lifecycle_preserves_machine_output(self) -> None: output = io.StringIO() errors = io.StringIO() - with patch.object(terminal.sys, "stdout", output), patch.object( + with ( + patch.object(terminal.sys, "stdout", output), + patch.object( terminal.sys, "stderr", errors, - ), patch.object(terminal, "_clear_terminal") as clear_terminal: + ), + patch.object(terminal, "_clear_terminal") as clear_terminal, + ): with terminal.terminal_lifecycle(): output.write("machine output") @@ -93,11 +106,15 @@ def test_interactive_lifecycle_ends_with_a_newline_after_an_exception(self) -> N output = _InteractiveBuffer() errors = _InteractiveBuffer() - with patch.object(terminal.sys, "stdout", output), patch.object( + with ( + patch.object(terminal.sys, "stdout", output), + patch.object( terminal.sys, "stderr", errors, - ), patch.object(terminal, "_clear_terminal") as clear_terminal: + ), + patch.object(terminal, "_clear_terminal") as clear_terminal, + ): with self.assertRaisesRegex(RuntimeError, "interrupted"): with terminal.terminal_lifecycle(): output.write("command output") diff --git a/tests/test_test_runner_cli.py b/tests/test_test_runner_cli.py index 92bb0cd9..520e52ad 100644 --- a/tests/test_test_runner_cli.py +++ b/tests/test_test_runner_cli.py @@ -22,11 +22,15 @@ def test_runner_requires_activating_the_existing_local_environment(self) -> None activation_script.write_text("", encoding="utf-8") output = io.StringIO() - with patch.object(test_runner, "project_root", return_value=root), patch.object( + with ( + patch.object(test_runner, "project_root", return_value=root), + patch.object( sys, "prefix", sys.base_prefix, - ), patch("sys.stderr", output): + ), + patch("sys.stderr", output), + ): self.assertEqual(2, test_runner.main([])) self.assertIn(r".\.venv\Scripts\Activate.ps1", output.getvalue()) @@ -36,11 +40,15 @@ def test_runner_requires_the_installer_when_the_local_environment_is_missing(sel with tempfile.TemporaryDirectory() as temporary: output = io.StringIO() - with patch.object(test_runner, "project_root", return_value=Path(temporary)), patch.object( + with ( + patch.object(test_runner, "project_root", return_value=Path(temporary)), + patch.object( sys, "prefix", sys.base_prefix, - ), patch("sys.stderr", output): + ), + patch("sys.stderr", output), + ): self.assertEqual(2, test_runner.main([])) rendered = " ".join(output.getvalue().split()) @@ -58,11 +66,17 @@ def test_runner_reports_discovered_suite_through_the_application_console(self) - runner = MagicMock() runner.run.return_value = result - with patch("logicytics.module.configuration.load_config", return_value=MagicMock()), \ - patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), \ - patch("logicytics.module.logging.get_application_logger", return_value=logger), \ - patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite()), \ - patch.object(test_runner.unittest, "TextTestRunner", return_value=runner): + with ( + patch("logicytics.module.configuration.load_config", return_value=MagicMock()), + patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), + patch("logicytics.module.logging.get_application_logger", return_value=logger), + patch.object( + test_runner.unittest.defaultTestLoader, + "discover", + return_value=unittest.TestSuite(), + ), + patch.object(test_runner.unittest, "TextTestRunner", return_value=runner), + ): self.assertEqual(0, test_runner.main(["--verbosity", "0"])) logger.box.assert_called_once_with( @@ -92,11 +106,17 @@ def run(self, _: unittest.TestSuite) -> MagicMock: self.stream.write("FAIL: example test\nassertion failed\n") return result - with patch("logicytics.module.configuration.load_config", return_value=MagicMock()), \ - patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), \ - patch("logicytics.module.logging.get_application_logger", return_value=logger), \ - patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite()), \ - patch.object(test_runner.unittest, "TextTestRunner", FailingRunner): + with ( + patch("logicytics.module.configuration.load_config", return_value=MagicMock()), + patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), + patch("logicytics.module.logging.get_application_logger", return_value=logger), + patch.object( + test_runner.unittest.defaultTestLoader, + "discover", + return_value=unittest.TestSuite(), + ), + patch.object(test_runner.unittest, "TextTestRunner", FailingRunner), + ): self.assertEqual(1, test_runner.main([])) logger.box.assert_any_call("Test failures", ("FAIL: example test", "assertion failed")) diff --git a/tests/test_windows_features_collector.py b/tests/test_windows_features_collector.py index 189a1c3e..a8a03208 100644 --- a/tests/test_windows_features_collector.py +++ b/tests/test_windows_features_collector.py @@ -22,9 +22,7 @@ class WindowsFeaturesCollectorTests(unittest.TestCase): def test_power_shell_dism_access_error_is_recognized(self) -> None: """DISM's 'access to ... is denied' wording must map to a skipped collector.""" module = _module() - self.assertTrue( - module._is_access_denied("Set current directory failed: Access to the path is denied.") - ) + self.assertTrue(module._is_access_denied("Set current directory failed: Access to the path is denied.")) if __name__ == "__main__": diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py index 442b4732..f0ab6df6 100644 --- a/tests/test_windows_integration.py +++ b/tests/test_windows_integration.py @@ -11,7 +11,10 @@ from logicytics.module.discovery import preflight from logicytics.module.output_contracts import core_output_contract from logicytics.platform_adapters import ( - process_adapter, registry_adapter, which, windows_api_adapter, + process_adapter, + registry_adapter, + which, + windows_api_adapter, ) @@ -23,8 +26,8 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: self.assertIn(privilege, {True, False, None}) with registry_adapter.OpenKey( - registry_adapter.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", ) as key: product_name, _ = registry_adapter.QueryValueEx(key, "ProductName") self.assertTrue(str(product_name).strip()) @@ -32,11 +35,27 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: powershell = which("powershell") self.assertIsNotNone(powershell) probes = { - "powershell": [powershell, "-NoProfile", "-NonInteractive", "-Command", "Get-ExecutionPolicy"], - "wmi": [powershell, "-NoProfile", "-NonInteractive", "-Command", - "Get-CimInstance Win32_OperatingSystem | Select-Object -First 1 Caption | ConvertTo-Json -Compress"], - "event_log": [powershell, "-NoProfile", "-NonInteractive", "-Command", - "Get-WinEvent -LogName System -MaxEvents 1 | Select-Object Id | ConvertTo-Json -Compress"], + "powershell": [ + powershell, + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-ExecutionPolicy", + ], + "wmi": [ + powershell, + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-CimInstance Win32_OperatingSystem | Select-Object -First 1 Caption | ConvertTo-Json -Compress", + ], + "event_log": [ + powershell, + "-NoProfile", + "-NonInteractive", + "-Command", + "Get-WinEvent -LogName System -MaxEvents 1 | Select-Object Id | ConvertTo-Json -Compress", + ], } ipconfig = which("ipconfig") self.assertIsNotNone(ipconfig) @@ -49,9 +68,7 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: probes["wmic"] = [wmic, "computersystem", "get", "Name", "/format:list"] for name, command in probes.items(): with self.subTest(integration=name): - result = process_adapter.run( - command, capture_output=True, check=False, text=True, timeout=30 - ) + result = process_adapter.run(command, capture_output=True, check=False, text=True, timeout=30) self.assertIsInstance(result.returncode, int) self.assertLessEqual(len(result.stdout.encode("utf-8")), process_adapter.maximum_capture_bytes) self.assertLessEqual(len(result.stderr.encode("utf-8")), process_adapter.maximum_capture_bytes) @@ -60,9 +77,7 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: sysinternals = [which(name) for name in ("autorunsc", "handle", "pslist")] for executable in (path for path in sysinternals if path is not None): with self.subTest(integration="sysinternals", executable=executable): - result = process_adapter.run( - [executable, "-?"], capture_output=True, check=False, text=True, timeout=20 - ) + result = process_adapter.run([executable, "-?"], capture_output=True, check=False, text=True, timeout=20) self.assertIsInstance(result.returncode, int) def test_every_shipped_output_contract_publishes_on_windows(self) -> None: @@ -99,8 +114,8 @@ def test_every_shipped_output_contract_publishes_on_windows(self) -> None: ) for pattern, media_type in zip( - contract.workspace_patterns, - contract.media_types, + contract.workspace_patterns, + contract.media_types, ): parts: list[str] = [] @@ -121,10 +136,7 @@ def test_every_shipped_output_contract_publishes_on_windows(self) -> None: self.assertIn( artifact.relative_path, - { - f"{metadata.id.replace('.', '_')}/" - f"{source.relative_to(workspace).as_posix()}" - }, + {f"{metadata.id.replace('.', '_')}/{source.relative_to(workspace).as_posix()}"}, ) diff --git a/tests/test_wmic_inventory_collector.py b/tests/test_wmic_inventory_collector.py index 3cc0de38..26372b45 100644 --- a/tests/test_wmic_inventory_collector.py +++ b/tests/test_wmic_inventory_collector.py @@ -9,8 +9,8 @@ from unittest.mock import Mock, patch from core.system import wmic_inventory -from logicytics.module.artifacts import WorkspaceArtifactWriter from logicytics.contracts import CollectorContext, CollectorStatus +from logicytics.module.artifacts import WorkspaceArtifactWriter class WmicInventoryCollectorTests(unittest.TestCase): @@ -26,23 +26,24 @@ def test_wmic_inventory_runs_the_bounded_read_only_query(self) -> None: collector_id="core.system.wmic_inventory", workspace=workspace, temporary_directory=workspace / "tmp", - artifacts=WorkspaceArtifactWriter( - "core.system.wmic_inventory", workspace, artifacts, 256 * 1024, 1 - ), + artifacts=WorkspaceArtifactWriter("core.system.wmic_inventory", workspace, artifacts, 256 * 1024, 1), logger=Mock(), settings={}, cancellation_file=workspace / ".cancelled", ) response = "Manufacturer=Example Corp\r\nModel=Example Model\r\n" - with patch.object( + with ( + patch.object( wmic_inventory, wmic_inventory.which.__name__, return_value="C:/Windows/System32/wbem/WMIC.exe", - ), patch.object( - wmic_inventory.subprocess, - wmic_inventory.subprocess.run.__name__, - return_value=subprocess.CompletedProcess((), 0, response, ""), - ) as run: + ), + patch.object( + wmic_inventory.subprocess, + wmic_inventory.subprocess.run.__name__, + return_value=subprocess.CompletedProcess((), 0, response, ""), + ) as run, + ): collector = wmic_inventory.WmicInventoryCollector() self.assertTrue(collector.validate(context).valid) result = collector.collect(context) @@ -72,9 +73,9 @@ def test_absent_wmic_is_an_explicit_skip(self) -> None: cancellation_file=root / ".cancelled", ) with patch.object( - wmic_inventory, - wmic_inventory.which.__name__, - return_value=None, + wmic_inventory, + wmic_inventory.which.__name__, + return_value=None, ): collector = wmic_inventory.WmicInventoryCollector() validation = collector.validate(context) diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index 012841ca..d0cc9eb7 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -5,13 +5,13 @@ import unittest from pathlib import Path -from logicytics.module.configuration import ( - default_config, -) from logicytics.contracts import ( Capability, RunRequest, ) +from logicytics.module.configuration import ( + default_config, +) from logicytics.module.discovery import preflight from logicytics.module.planner import build_plan from logicytics.module.runtime import RunSupervisor @@ -48,12 +48,13 @@ def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> Non outcome = RunSupervisor(root, default_config(root)).run(plan) records = {record.id: record for record in outcome.manifest.collectors} self.assertEqual("failed", records["core.system.a_attacker"].status) - self.assertIn("escapes its private workspace", "\n".join(records["core.system.a_attacker"].errors)) + self.assertIn( + "escapes its private workspace", + "\n".join(records["core.system.a_attacker"].errors), + ) self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertFalse((root / "repository-compromised.txt").exists()) - self.assertFalse( - (outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists() - ) + self.assertFalse((outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists()) def test_collector_cannot_mutate_its_process_environment(self) -> None: """A collector must not change process environment or working-directory policy.""" @@ -65,8 +66,7 @@ def test_collector_cannot_mutate_its_process_environment(self) -> None: collector_path.write_text( COLLECTOR.replace("from pathlib import Path\n", "from pathlib import Path\nimport os\n").replace( ' output = context.workspace / "system.txt"', - ' os.environ["LOGICYTICS_COLLECTOR_MUTATION"] = "unexpected"\n' - ' output = context.workspace / "system.txt"', + ' os.environ["LOGICYTICS_COLLECTOR_MUTATION"] = "unexpected"\n output = context.workspace / "system.txt"', ), encoding="utf-8", ) @@ -85,11 +85,7 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( base = COLLECTOR.replace( "from pathlib import Path\n", - "from pathlib import Path\n" - "import socket\n" - "import subprocess\n" - "import sys\n" - "import winreg\n", + "from pathlib import Path\nimport socket\nimport subprocess\nimport sys\nimport winreg\n", ) attempts = ( @@ -112,8 +108,7 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( collector_path.write_text( base.replace( ' output = context.workspace / "system.txt"', - f' {operation}\n' - ' output = context.workspace / "system.txt"', + f' {operation}\n output = context.workspace / "system.txt"', ), encoding="utf-8", ) @@ -146,21 +141,19 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( remediation = failure["remediation"] if not isinstance(remediation, str): - self.fail( - f"remediation must be str, got {type(remediation).__name__}" - ) + self.fail(f"remediation must be str, got {type(remediation).__name__}") self.assertIn("capability", remediation) retry_safe = failure["retry_safe"] if not isinstance(retry_safe, bool): - self.fail( - f"retry_safe must be bool, got {type(retry_safe).__name__}" - ) + self.fail(f"retry_safe must be bool, got {type(retry_safe).__name__}") self.assertFalse(retry_safe) - def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities(self) -> None: + def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities( + self, + ) -> None: """External evidence reads require filesystem access plus every applicable sensitive grant.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -179,7 +172,11 @@ def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabil (ordinary, (), "filesystem_read capability"), (ordinary, (Capability.FILESYSTEM_READ,), None), (browser, (Capability.FILESYSTEM_READ,), "browser_data capability"), - (browser, (Capability.FILESYSTEM_READ, Capability.BROWSER_DATA), "sensitive_files capability"), + ( + browser, + (Capability.FILESYSTEM_READ, Capability.BROWSER_DATA), + "sensitive_files capability", + ), ( private_key, (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES), @@ -187,7 +184,11 @@ def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabil ), ( private_key, - (Capability.FILESYSTEM_READ, Capability.SENSITIVE_FILES, Capability.PRIVATE_KEYS), + ( + Capability.FILESYSTEM_READ, + Capability.SENSITIVE_FILES, + Capability.PRIVATE_KEYS, + ), None, ), ) @@ -200,10 +201,12 @@ def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabil COLLECTOR.replace( "from pathlib import Path\n", "from pathlib import Path\nfrom logicytics import Capability\n", - ).replace( - ' capabilities=(),', - f' capabilities=({declared}),', - ).replace( + ) + .replace( + " capabilities=(),", + f" capabilities=({declared}),", + ) + .replace( ' output = context.workspace / "system.txt"', f" Path({str(evidence)!r}).read_text(encoding='utf-8')\n" ' output = context.workspace / "system.txt"', @@ -240,13 +243,14 @@ def test_worker_rejects_raw_packet_socket_without_packet_capture_capability(self COLLECTOR.replace( "from pathlib import Path\n", "from pathlib import Path\nimport socket\nfrom logicytics import Capability\n", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.NETWORK,),\n' - ' network_access=NetworkAccess.LOCAL,', - ).replace( + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.NETWORK,),\n network_access=NetworkAccess.LOCAL,", + ) + .replace( ' output = context.workspace / "system.txt"', - ' socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)\n' + " socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_IP)\n" ' output = context.workspace / "system.txt"', ), encoding="utf-8", @@ -274,27 +278,30 @@ def test_worker_allows_an_explicitly_declared_subprocess_by_default(self) -> Non COLLECTOR.replace( "from pathlib import Path\n", "from pathlib import Path\nimport subprocess\nimport sys\nfrom logicytics import Capability\n", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.SUBPROCESS,),', - ).replace( - ' output = context.workspace / "system.txt"', - ' subprocess.run([sys.executable, "-c", "pass"], check=True)\n' + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.SUBPROCESS,),", + ) + .replace( ' output = context.workspace / "system.txt"', + ' subprocess.run([sys.executable, "-c", "pass"], check=True)\n output = context.workspace / "system.txt"', ), encoding="utf-8", ) plan = build_plan( preflight(root), - RunRequest( - max_workers=1, - acknowledge_authorization=True, - ), + RunRequest( + max_workers=1, + acknowledge_authorization=True, + ), ) outcome = RunSupervisor(root, default_config(root)).run(plan) self.assertEqual("succeeded", outcome.manifest.collectors[0].status) - def test_worker_blocks_application_update_power_and_peer_collector_commands_without_stopping_peers(self) -> None: + def test_worker_blocks_application_update_power_and_peer_collector_commands_without_stopping_peers( + self, + ) -> None: """Subprocess approval cannot escape the collector role or affect an independent worker.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -322,16 +329,20 @@ def test_worker_blocks_application_update_power_and_peer_collector_commands_with ) for command, category in attempts: with self.subTest(command=command): - attacker = delayed_collector_source("a_attacker", 0.0).replace( - "from pathlib import Path\n", - "from pathlib import Path\nimport subprocess\nfrom logicytics import Capability\n", - ).replace( - ' capabilities=(),', - ' capabilities=(Capability.SUBPROCESS,),', - ).replace( - ' output = context.workspace / "system.txt"', - f" subprocess.run({command!r}, check=True)\n" - ' output = context.workspace / "system.txt"', + attacker = ( + delayed_collector_source("a_attacker", 0.0) + .replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport subprocess\nfrom logicytics import Capability\n", + ) + .replace( + " capabilities=(),", + " capabilities=(Capability.SUBPROCESS,),", + ) + .replace( + ' output = context.workspace / "system.txt"', + f' subprocess.run({command!r}, check=True)\n output = context.workspace / "system.txt"', + ) ) (core_directory / "a_attacker.py").write_text(attacker, encoding="utf-8") report = preflight(root) @@ -356,7 +367,9 @@ def test_worker_blocks_application_update_power_and_peer_collector_commands_with self.assertEqual("partial", outcome.manifest.status.value) self.assertFalse(protected_configuration.exists()) - def test_preflight_rejects_collector_imports_of_application_and_orchestration_services(self) -> None: + def test_preflight_rejects_collector_imports_of_application_and_orchestration_services( + self, + ) -> None: """Collectors may import public contracts but never the main application control surface.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) @@ -380,10 +393,7 @@ def test_preflight_rejects_collector_imports_of_application_and_orchestration_se report = preflight(root) self.assertEqual(1, len(report.invalid)) self.assertIn("forbidden application import", "\n".join(report.invalid[0].static_errors)) - diagnostic = next( - item for item in report.invalid[0].diagnostics - if "forbidden application import" in item.message - ) + diagnostic = next(item for item in report.invalid[0].diagnostics if "forbidden application import" in item.message) self.assertEqual("static.engine_boundary", diagnostic.rule) From 74c5972e1d2f90bcc40b6fc5026d4f04fae2bfe6 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 23:11:58 +0300 Subject: [PATCH 272/316] fix(console): remove box framing from banners and alerts Render startup banners and alerts with plain titles, full-width ASCII rules, and indented details instead of corner and vertical border glyphs. Update console tests to enforce the cleaner presentation. --- logicytics/module/presentation.py | 50 +++++++++---------------------- tests/test_logging.py | 8 +++-- tests/test_terminal.py | 9 ++++-- 3 files changed, 25 insertions(+), 42 deletions(-) diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index 136f49f5..e9fe7219 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -46,14 +46,13 @@ def _heading(title: str, *, width: ConsoleWidth = console_width) -> str: def render_banner(console: TextIO, *, width: ConsoleWidth = terminal_width) -> None: """Render a full-width, strictly ASCII startup banner after a real clear.""" - available = max(width() - 2, _MIN_CONSOLE_WIDTH - 2) - title = " LOGICYTICS " + available = max(width(), _MIN_CONSOLE_WIDTH) + title = "LOGICYTICS" subtitle = "Local evidence collection framework" rows = ( - "+" + "-" * available + "+", - "|" + title.center(available) + "|", - "|" + subtitle.center(available) + "|", - "+" + "-" * available + "+", + title, + "-" * available, + subtitle, "", ) console.write("\n".join(rows)) @@ -71,11 +70,6 @@ def render_step_heading( console.flush() -def _alert_symbols() -> tuple[str, str, str, str, str, str, str, str]: - """Return an unambiguous ASCII alert frame for every terminal.""" - return ("+", "-", "+", "|", "+", "+", "!", ">") - - def render_section( console: TextIO, title: str, @@ -122,39 +116,23 @@ def render_alert( message_lines: MessageLines = _plain_message_lines, width: ConsoleWidth = console_width, ) -> None: - """Render a severity-marked, bordered alert with wrapped actionable details.""" - ( - top_left, - horizontal, - top_right, - vertical, - bottom_left, - bottom_right, - error_marker, - next_step_marker, - ) = _alert_symbols() - inner_width = min(max(width(), _MIN_CONSOLE_WIDTH), 88) - 2 + """Render a severity-marked alert with a clean title and ASCII rule.""" + available = max(width(), _MIN_CONSOLE_WIDTH) safe_title = redact_text(title).strip() - heading = f" {safe_title} "[:inner_width] - rows = [ - f"{top_left}{heading}{horizontal * max(inner_width - len(heading), 0)}{top_right}", - ] + rows = [safe_title, "-" * available, ""] for line_index, line in enumerate(lines): - marker = error_marker if line_index == 0 else next_step_marker - prefix = f" {marker} " - continuation_prefix = " " + marker = "!" if line_index == 0 else ">" + prefix = f" {marker} " + continuation_prefix = " " for raw_row in line.splitlines() or [""]: for presentation_row in message_lines(raw_row.strip()): wrapped = textwrap.wrap( presentation_row, - width=max(inner_width - len(prefix), 1), + width=max(available - len(prefix), 1), break_long_words=True, break_on_hyphens=False, ) or [""] - rows.append(f"{vertical}{prefix}{wrapped[0]:<{inner_width - len(prefix)}}{vertical}") - rows.extend( - f"{vertical}{continuation_prefix}{row:<{inner_width - len(continuation_prefix)}}{vertical}" for row in wrapped[1:] - ) - rows.append(f"{bottom_left}{horizontal * inner_width}{bottom_right}") + rows.append(prefix + wrapped[0]) + rows.extend(f"{continuation_prefix}{row}" for row in wrapped[1:]) console.write("\n".join(rows) + "\n") console.flush() diff --git a/tests/test_logging.py b/tests/test_logging.py index 4746ccaa..91e03cf3 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -69,9 +69,11 @@ def test_virtual_environment_error_uses_the_logging_alert_formatter(self) -> Non render_virtual_environment_error(actual, root) self.assertEqual(expected.getvalue(), actual.getvalue()) - self.assertIn("+ Logicytics startup error", actual.getvalue()) - self.assertIn("| ! ", actual.getvalue()) - self.assertIn("| > ", actual.getvalue()) + self.assertIn("Logicytics startup error\n", actual.getvalue()) + self.assertIn(" ! ", actual.getvalue()) + self.assertIn(" > ", actual.getvalue()) + self.assertNotIn("+", actual.getvalue()) + self.assertNotIn("|", actual.getvalue()) def test_application_logging_levels_colors_retention_and_dispatch_are_bounded(self) -> None: """The application sink is typed, redacted, reusable, colored, and size bounded.""" diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 09cfe702..27dbdbd9 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -55,9 +55,12 @@ def test_banner_is_full_width_and_strictly_ascii(self) -> None: render_banner(console, width=lambda: 101) rows = console.getvalue().splitlines() - self.assertEqual(101, len(rows[0])) - self.assertEqual("+", rows[0][0]) - self.assertEqual("+", rows[0][-1]) + self.assertEqual("LOGICYTICS", rows[0]) + self.assertEqual(101, len(rows[1])) + self.assertTrue(set(rows[1]) == {"-"}) + self.assertEqual("Local evidence collection framework", rows[2]) + self.assertNotIn("+", console.getvalue()) + self.assertNotIn("|", console.getvalue()) self.assertTrue(all(ord(character) < 128 for row in rows for character in row)) def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None: From 4f4e7bc00bbe7fc4ad64156674681a3e70b515bd Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Tue, 8 Sep 2026 23:20:16 +0300 Subject: [PATCH 273/316] feat(preflight): report validation progress Expose collector validation progress through an optional preflight callback and render throttled checked/total updates in CLI preflight workflows. --- logicytics/cli/commands.py | 24 ++++++++++++++++++++++++ logicytics/module/discovery.py | 21 +++++++++++++++++++-- tests/test_logging.py | 16 ++++++++++++++++ tests/test_preflight.py | 15 +++++++++++++++ 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 4e4d0320..186fe86f 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -663,6 +663,27 @@ def finish_command( history_path = configuration.runtime.output_root / "interaction_history.json.gz" + last_preflight_progress_at = 0.0 + + def report_preflight_progress(phase: str, checked: int, total: int, current: str) -> None: + """Keep long sequential collector validation visibly alive without flooding output.""" + nonlocal last_preflight_progress_at + now = perf_counter() + should_report = ( + last_preflight_progress_at == 0.0 or now - last_preflight_progress_at >= 1.0 or (phase == "checked" and checked == total) + ) + if should_report: + application_logger.event( + "INFO", + "preflight_progress", + source="logicytics.cli", + phase="validating collectors", + checked=checked, + total=total, + current=current, + ) + last_preflight_progress_at = now + if arguments.command == "match": history = load_history(history_path) @@ -740,6 +761,7 @@ def finish_command( report = preflight( root, configuration_hash=modes_configuration_hash, + progress=report_preflight_progress, ) application_logger.event( "INFO", @@ -780,9 +802,11 @@ def finish_command( source="logicytics.cli", configuration_hash=configuration.fingerprint(), ) + report = preflight( root, configuration_hash=configuration.fingerprint(), + progress=report_preflight_progress, ) application_logger.event( "INFO", diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index ca287c01..9788432b 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -9,6 +9,7 @@ import re import sys import tempfile +from collections.abc import Callable from dataclasses import asdict, dataclass, field from pathlib import Path from subprocess import TimeoutExpired @@ -54,6 +55,8 @@ "logicytics.platform_adapters", } +PreflightProgress = Callable[[str, int, int, str], None] + @dataclass(frozen=True, slots=True) class ValidationDiagnostic: @@ -824,14 +827,26 @@ def _write_cache(project_root: Path, configuration_hash: str, candidates: list[C temporary.replace(path) -def preflight(project_root: Path, *, configuration_hash: str = "unconfigured") -> PreflightReport: +def preflight( + project_root: Path, + *, + configuration_hash: str = "unconfigured", + progress: PreflightProgress | None = None, +) -> PreflightReport: """Perform static checks then a short-lived isolated metadata probe.""" candidates = list(discover(project_root)) cached = _load_cache(project_root, configuration_hash) - for candidate in candidates: + total = len(candidates) + for index, candidate in enumerate(candidates, start=1): + if progress is not None: + progress("checking", index - 1, total, candidate.selection_id) if candidate.static_errors: + if progress is not None: + progress("checked", index, total, candidate.selection_id) continue if candidate.kind is CollectorKind.MOD: + if progress is not None: + progress("checked", index, total, candidate.selection_id) continue relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() entry = cached.get(relative_path) @@ -843,6 +858,8 @@ def preflight(project_root: Path, *, configuration_hash: str = "unconfigured") - _accept_runtime_metadata(candidate, entry.get("metadata")) else: _runtime_probe(project_root, candidate) + if progress is not None: + progress("checked", index, total, candidate.selection_id) seen_ids: set[str] = set() for candidate in candidates: if candidate.metadata is None: diff --git a/tests/test_logging.py b/tests/test_logging.py index 91e03cf3..b4bf6ff7 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -299,6 +299,14 @@ def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> ) logger.event("INFO", "command_started", command="preflight") logger.event("INFO", "preflight_started", configuration_hash="a" * 64) + logger.event( + "INFO", + "preflight_progress", + phase="validating collectors", + checked=12, + total=66, + current="core.system.system_info", + ) logger.event("INFO", "run_packaging_started", run_id="run-test") logger.event("INFO", "run_packaged", run_id="run-test") @@ -312,6 +320,14 @@ def test_application_logging_groups_lifecycle_events_into_titled_steps(self) -> " ● Preflight started\n > Configuration hash: aaaaaaa", rendered, ) + self.assertIn( + " ● Preflight progress\n" + " > Phase: validating collectors\n" + " > Checked: 12\n" + " > Total: 66\n" + " > Current: core.system.system_info", + rendered, + ) self.assertEqual(1, rendered.count("Packaging\n")) def test_application_logging_compacts_configuration_hash_only_outside_debug(self) -> None: diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 45e75eb1..add53e43 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -119,6 +119,21 @@ def test_preflight_cache_requires_source_interpreter_contract_and_configuration_ self.assertEqual(1, len(invalidated.valid), invalidated.invalid) self.assertEqual(1, probe.call_count) + def test_preflight_reports_collector_progress_before_and_after_validation(self) -> None: + """Long-running isolated probes expose their current collector to callers.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + collector_path.write_text(COLLECTOR, encoding="utf-8") + progress: list[tuple[str, int, int, str]] = [] + + report = preflight(root, progress=lambda phase, checked, total, current: progress.append((phase, checked, total, current))) + + self.assertEqual(1, len(report.valid), report.invalid) + self.assertEqual(("checking", 0, 1, "core.system_info"), progress[0]) + self.assertEqual(("checked", 1, 1, "core.system.system_info"), progress[-1]) + def test_preflight_requires_exact_declared_artifact_media_types(self) -> None: """A collector cannot disguise or omit the output contract used by registration.""" with tempfile.TemporaryDirectory() as temporary: From 31e5c79a26fed6faac2e7a7e350c3375875eb82e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 07:18:02 +0300 Subject: [PATCH 274/316] fix(runtime): isolate worker temporary directories Route collector and child-process temporary files into each worker workspace so broken host TEMP or TMP locations cannot fail depth runs. Expose text-mode process results accurately and preserve a clear unsupported-platform error for process-group termination. --- logicytics/module/runtime.py | 27 +++++++++++++++++++++++ logicytics/platform_adapters.py | 23 ++++++++++++++++++-- tests/test_runtime.py | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index 746788a5..69fc27bf 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -14,6 +14,7 @@ import shutil import socket import sys +import tempfile import traceback import zipfile from collections.abc import Callable, Mapping @@ -598,6 +599,27 @@ def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage] result_queue.put({"collector_id": metadata.id, "result": _serialize_result(result)}) +def _configure_worker_temporary_directory(directory: Path) -> Callable[[], None]: + """Route stdlib and child-process temporary files into one worker workspace.""" + location = str(directory.resolve()) + previous_tempdir = tempfile.tempdir + previous_environment = {name: os.environ.get(name) for name in ("TEMP", "TMP")} + tempfile.tempdir = location + os.environ["TEMP"] = location + os.environ["TMP"] = location + + def restore() -> None: + """Restore the inherited process temporary-directory configuration.""" + tempfile.tempdir = previous_tempdir + for name, previous_value in previous_environment.items(): + if previous_value is None: + os.environ.pop(name, None) + else: + os.environ[name] = previous_value + + return restore + + def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) -> None: """Run a single collector in an isolated child process.""" if os.name != "nt": @@ -606,6 +628,7 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) workspace.mkdir(parents=True, exist_ok=True) temporary_directory = workspace / "tmp" temporary_directory.mkdir(exist_ok=True) + restore_temporary_directory = _configure_worker_temporary_directory(temporary_directory) if payload["execution_type"] != "collector": try: _run_mod_worker(payload, result_queue) @@ -621,6 +644,8 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) ), } ) + finally: + restore_temporary_directory() return stdout_path = workspace / "stdout.log" stderr_path = workspace / "stderr.log" @@ -776,6 +801,8 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) ), } ) + finally: + restore_temporary_directory() def _serialize_result(result: CollectorResult) -> _SerializedResult: diff --git a/logicytics/platform_adapters.py b/logicytics/platform_adapters.py index 1916bfd0..7a613d8c 100644 --- a/logicytics/platform_adapters.py +++ b/logicytics/platform_adapters.py @@ -14,7 +14,7 @@ from collections.abc import Sequence from datetime import UTC, datetime, timedelta from pathlib import Path -from typing import Any +from typing import Any, Literal, overload _ctypes_collector = importlib.import_module("logicytics.global.ctypes_collector") query_registry_key_info = _ctypes_collector.query_registry_key_info @@ -44,6 +44,22 @@ def _command( return normalized + @staticmethod + @overload + def run( + command: Sequence[str | os.PathLike[str]], + *, + text: Literal[True], + **options: Any, + ) -> subprocess.CompletedProcess[str]: ... + + @staticmethod + @overload + def run( + command: Sequence[str | os.PathLike[str]], + **options: Any, + ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]: ... + @staticmethod def run( command: Sequence[str | os.PathLike[str]], @@ -156,7 +172,10 @@ def terminate_process_group(process_id: int) -> None: if not isinstance(process_id, int) or isinstance(process_id, bool) or process_id <= 0: raise ValueError("process_id must be a positive integer") - os.killpg(process_id, signal.SIGTERM) + kill_process_group = getattr(os, "killpg", None) + if kill_process_group is None: + raise OSError("process groups are unavailable on this platform") + kill_process_group(process_id, signal.SIGTERM) process_adapter = ProcessAdapter() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index d44a8cb2..cc962919 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -1,3 +1,5 @@ +"""Regression coverage for isolated collector runtime behavior.""" + from __future__ import annotations import json @@ -164,6 +166,42 @@ def test_failed_collector_is_manifested_and_never_reported_as_success(self) -> N self.assertIn(f"Started: {record.started_at}", summary) self.assertIn(f"Finished: {record.finished_at}", summary) + def test_worker_uses_its_private_temporary_directory_when_host_temp_is_unusable(self) -> None: + """A collector process must not inherit a broken host TEMP or TMP directory.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + collector_path = root / "core" / "system" / "system_info.py" + collector_path.parent.mkdir(parents=True) + (root / "plugins").mkdir() + collector_path.write_text( + COLLECTOR.replace( + "from pathlib import Path\n", + "from pathlib import Path\nimport tempfile\n", + ).replace( + 'output.write_text("ok\\n", encoding="utf-8")', + "output.write_text(tempfile.gettempdir(), encoding=\"utf-8\")", + ), + encoding="utf-8", + ) + unavailable = root / "unavailable-temp" + + with patch.dict(os.environ, {"TEMP": str(unavailable), "TMP": str(unavailable)}): + plan = build_plan(preflight(root), RunRequest(max_workers=1, acknowledge_authorization=True)) + outcome = RunSupervisor(root, default_config(root)).run(plan) + + record = outcome.manifest.collectors[0] + package = outcome.manifest.package + assert package is not None + + with zipfile.ZipFile(Path(package["path"])) as archive: + observed = archive.read("evidence/derived/core_system_system_info/system.txt").decode("utf-8") + + self.assertEqual("succeeded", record.status, record.errors) + self.assertEqual( + ("collectors", "core_system_system_info", "tmp"), + Path(observed).parts[-3:], + ) + def test_transient_collector_failure_retries_in_a_new_isolated_worker(self) -> None: """An explicitly retryable collector may recover before any evidence is registered.""" with tempfile.TemporaryDirectory() as temporary: From bb3aeeab607de53de28a90c4be861714c1cb0d19 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 07:18:15 +0300 Subject: [PATCH 275/316] fix(cli): validate config placement and repository reachability Accept --config after a subcommand and make update and dev validate Git, the worktree, origin, and a read-only remote query. Report unavailable Git executables and unreachable remotes through structured diagnostics instead of generic command failures. --- README.md | 12 ++- logicytics/__init__.py | 14 +++ logicytics/cli/commands.py | 166 +++++++++++++++++++++++-------- logicytics/module/maintenance.py | 8 +- tests/test_cli.py | 80 +++++++++++++-- tests/test_maintenance.py | 24 +++++ 6 files changed, 250 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 79aefc0b..bc94b35a 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ action for the parser-generated help. ### Global flags -These flags go before the action name: +`--config PATH` may be placed before or after an action name. The other global +flags go before the action name: | Flag | Description | |---------------|---------------------------------------------------------------------------| @@ -146,7 +147,7 @@ isolated worker with the unique `CAPABILITY_DECLARATION_MISMATCH` diagnostic. | Profile | Selection policy | |----------|---------------------------------------------------------------------------------------------------------| | minimal | Essential local system, memory, and storage inventory. | -| standard | Shipped core collectors declaring standard membership. | +| standard | Routine local system and security configuration inventory (29 of 66 shipped collectors). | | deep | Extended declared inventory; sensitive and privileged work still needs explicit selection and approval. | | offline | Local-only inventory; network and packet-capture collectors are rejected. | @@ -273,8 +274,8 @@ python -m logicytics --config C:\path\to\logicytics.yaml debug ### Update -update checks Git availability and repository state. It is read-only unless ---apply is supplied. +update verifies Git, the current worktree, its `origin`, and that the remote +answers a read-only Git query. It is read-only unless --apply is supplied. | Flag | Description | |---------------------------------------|-------------------------------------------------------------------------------------| @@ -292,7 +293,8 @@ python -m logicytics update --launch-action preflight --new-window ### Developer checks dev runs repository organization and integrity checks without collecting -evidence. +evidence. It also reports whether the configured GitHub remote is reachable; +an unreachable remote makes the check fail. | Flag | Description | |------------------------|------------------------------------------------------------| diff --git a/logicytics/__init__.py b/logicytics/__init__.py index df22c1fa..543b1cbc 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -1,6 +1,7 @@ """Logicytics v4 public contracts and lazily loaded application API.""" import importlib +from typing import TYPE_CHECKING from logicytics.contracts import ( CONTRACT_VERSION, @@ -24,6 +25,19 @@ ValidationResult, ) +if TYPE_CHECKING: + from logicytics.module.api import ( + CollectorFailureSnapshot, + CollectorSnapshot, + RunSnapshot, + load_configuration, + open_artifact, + plan_run, + query_run, + read_artifact, + run_collection, + ) + _APPLICATION_EXPORTS = frozenset( { "CollectorFailureSnapshot", diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 186fe86f..5bf41b0b 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -7,6 +7,7 @@ import importlib.util import json import os +import subprocess import platform import sys from pathlib import Path @@ -60,7 +61,7 @@ class CLI: @staticmethod def render_preflight( - logger: object, + logger: ApplicationLogger, validation: dict[str, list[dict[str, object]]], sysinternals: dict[str, str], ) -> None: @@ -78,7 +79,8 @@ def render_preflight( ), ) for item in (*invalid, *quarantined): - diagnostics = item.get("diagnostics", []) + raw_diagnostics = item.get("diagnostics", []) + diagnostics = raw_diagnostics if isinstance(raw_diagnostics, list) else [] details = "; ".join( str(diagnostic.get("message", "invalid collector")) for diagnostic in diagnostics if isinstance(diagnostic, dict) ) @@ -264,6 +266,12 @@ def parser() -> argparse.ArgumentParser: subcommands = parser.add_subparsers(dest="command", parser_class=HumanArgumentParser) for command in ("preflight", "debug", "update", "dev", "plan", "run", "collector"): subparser = subcommands.add_parser(command, help=f"Run the {command} action.") + subparser.add_argument( + "--config", + type=Path, + default=argparse.SUPPRESS, + help="Path to the authoritative Logicytics YAML configuration file", + ) subparser.add_argument( "--profile", default=None, @@ -460,13 +468,90 @@ def launch_action_window(root: Path, action: str) -> int: ) return process.pid + @staticmethod + def repository_status(root: Path) -> dict[str, bool | int | str | None]: + """Check the local Git worktree, origin declaration, and remote reachability.""" + status: dict[str, bool | int | str | None] = { + "git_available": False, + "git_version": None, + "is_repository": False, + "origin_configured": False, + "remote_reachable": False, + "repository_returncode": None, + "origin_returncode": None, + "reachability_returncode": None, + } + try: + git = process_adapter.run( + ["git", "--version"], + capture_output=True, + check=False, + text=True, + ) + except OSError: + return status + + git_output = git.stdout if isinstance(git.stdout, str) else "" + status["git_available"] = git.returncode == 0 + status["git_version"] = git_output.strip() or None + if git.returncode != 0: + return status + + try: + repository = process_adapter.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=root, + capture_output=True, + check=False, + text=True, + ) + except OSError: + return status + status["repository_returncode"] = repository.returncode + repository_output = repository.stdout if isinstance(repository.stdout, str) else "" + status["is_repository"] = repository.returncode == 0 and repository_output.strip().casefold() == "true" + if not status["is_repository"]: + return status + + try: + origin = process_adapter.run( + ["git", "remote", "get-url", "origin"], + cwd=root, + capture_output=True, + check=False, + text=True, + ) + except OSError: + return status + status["origin_returncode"] = origin.returncode + origin_output = origin.stdout if isinstance(origin.stdout, str) else "" + status["origin_configured"] = origin.returncode == 0 and bool(origin_output.strip()) + if not status["origin_configured"]: + return status + + try: + reachability = process_adapter.run( + ["git", "ls-remote", "--exit-code", "origin", "HEAD"], + cwd=root, + capture_output=True, + check=False, + text=True, + timeout=15, + ) + except (OSError, subprocess.TimeoutExpired): + return status + status["reachability_returncode"] = reachability.returncode + status["remote_reachable"] = reachability.returncode == 0 + return status + def run_developer_action( self, root: Path, configuration: AppConfig, arguments: argparse.Namespace, debug_logs: Path, - logger: object, + logger: ApplicationLogger, + repository: dict[str, bool | int | str | None], ) -> int: """Run read-only contribution checks and an explicitly confirmed manifest update.""" settings = configuration.maintenance @@ -548,6 +633,7 @@ def run_developer_action( payload = { "checks": checks, "comparison": comparison, + "repository": repository, "current_version": local_version(root), "existing_manifest_version": (existing.version if existing else None), "manifest_written": manifest_path, @@ -573,12 +659,13 @@ def run_developer_action( f"Extra files: {len(comparison['extra'])}", f"Unchanged files: {len(comparison['unchanged'])}", f"Organization issues: {organization_issues}", + f"GitHub reachable: {'yes' if repository['remote_reachable'] else 'no'}", f"Manifest written: {'yes' if manifest_path else 'no'}", f"Manifest path: {manifest_path or 'none'}", f"Diagnostic report: {development_path}", ), ) - return 0 + return 0 if organization_issues == 0 and repository["remote_reachable"] else 1 def main(argv: list[str] | None = None) -> int: @@ -722,6 +809,8 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str if arguments.command == "usage": statistics = usage_statistics(load_history(history_path)) + raw_average_accuracy = statistics.get("average_accuracy", 0.0) + average_accuracy = float(raw_average_accuracy) if isinstance(raw_average_accuracy, (int, float)) else 0.0 graph_path = write_usage_graph( configuration.runtime.output_root / "flag_usage.svg", @@ -738,7 +827,7 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str "Interaction usage", ( f"Total interactions: {statistics['total_interactions']}", - f"Average confidence: {float(statistics['average_accuracy']):.1%}", + f"Average confidence: {average_accuracy:.1%}", f"Common device: {statistics['common_device'] or 'none'}", f"Common input: {statistics['common_input'] or 'none'}", "Flag frequency:", @@ -772,11 +861,11 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str purpose="mode_matrix", ) - payload = mode_matrix((*report.valid, *report.invalid)) + matrix_payload = mode_matrix((*report.valid, *report.invalid)) matrix_path = layout.debug_logs / "modes.json" - cli_methods.write_json(matrix_path, payload) + cli_methods.write_json(matrix_path, matrix_payload) mode_rows = [] - for item in payload["modes"]: + for item in matrix_payload["modes"]: aliases = ", ".join(item["legacy_aliases"]) or "none" mode_rows.append(f"{item['name']}: {item['description']} ({len(item['collector_ids'])} collectors; aliases: {aliases})") application_logger.box( @@ -889,43 +978,36 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str if arguments.new_window != (arguments.launch_action is not None): raise ValueError("--new-window and --launch-action must be provided together") - git = process_adapter.run( - ["git", "--version"], - capture_output=True, - check=False, - text=True, - ) - - is_repository = (root / ".git").exists() - + repository = cli_methods.repository_status(root) payload: dict[str, object] = { - "git_available": git.returncode == 0, - "git_version": (git.stdout.strip() or None), - "is_repository": is_repository, + **repository, "applied": False, } pull_returncode: int | None = None + if not repository["remote_reachable"]: + update_path = layout.debug_logs / "update.json" + cli_methods.write_json(update_path, payload) + application_logger.box( + "Update result", + ( + f"Git available: {'yes' if repository['git_available'] else 'no'}", + f"Repository: {'yes' if repository['is_repository'] else 'no'}", + f"Origin configured: {'yes' if repository['origin_configured'] else 'no'}", + "GitHub reachable: no", + f"Diagnostic report: {update_path}", + "Update was not applied because the configured Git remote is unreachable.", + ), + ) + return finish_command( + 2, + status="repository_unreachable", + git_available=str(repository["git_available"]), + remote_reachable=str(repository["remote_reachable"]), + ) + if arguments.apply: - if git.returncode != 0 or not is_repository: - update_path = layout.debug_logs / "update.json" - cli_methods.write_json(update_path, payload) - application_logger.box( - "Update result", - ( - "Git available: no", - f"Repository: {'yes' if is_repository else 'no'}", - f"Diagnostic report: {update_path}", - "Update was not applied because Git or the repository is unavailable.", - ), - ) - return finish_command( - 2, - status="git_unavailable", - git_available=git.returncode == 0, - is_repository=is_repository, - ) pulled = process_adapter.run( ["git", "pull"], @@ -960,8 +1042,10 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str update_path = layout.debug_logs / "update.json" cli_methods.write_json(update_path, payload) update_lines = [ - f"Git available: {'yes' if git.returncode == 0 else 'no'}", - f"Repository: {'yes' if is_repository else 'no'}", + f"Git available: {'yes' if repository['git_available'] else 'no'}", + f"Repository: {'yes' if repository['is_repository'] else 'no'}", + f"Origin configured: {'yes' if repository['origin_configured'] else 'no'}", + f"GitHub reachable: {'yes' if repository['remote_reachable'] else 'no'}", f"Update applied: {'yes' if arguments.apply else 'no'}", f"Update status: {'succeeded' if update_succeeded else 'failed'}", ] @@ -989,12 +1073,14 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str ) if arguments.command == "dev": + repository = cli_methods.repository_status(root) exit_code = cli_methods.run_developer_action( root, configuration, arguments, layout.debug_logs, application_logger, + repository, ) return finish_command( exit_code, diff --git a/logicytics/module/maintenance.py b/logicytics/module/maintenance.py index 2b0ca429..09df5d62 100644 --- a/logicytics/module/maintenance.py +++ b/logicytics/module/maintenance.py @@ -20,6 +20,7 @@ _SHA256 = re.compile(r"^[0-9a-f]{64}$") _VERSION = re.compile(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+)|((?:a|b|rc|\.dev)\d+))?$") _MAXIMUM_MANIFEST_BYTES = 2 * 1024 * 1024 +_MAX_PUBLIC_FEATURES_PER_MODULE = 24 _EXCLUDED_PARTS = frozenset( { ".git", @@ -290,7 +291,7 @@ def developer_checks(project_root: Path, settings: MaintenanceSettings) -> Devel relative = path.relative_to(root) if path.suffix.casefold() != ".py": continue - if path.stem != "__init__" and re.fullmatch(r"[a-z][a-z0-9_]*", path.stem) is None: + if path.stem not in {"__init__", "__main__"} and re.fullmatch(r"[a-z][a-z0-9_]*", path.stem) is None: naming.append(relative.as_posix()) if relative.parts[0] not in {"CODE", "core", "logicytics", "plugins", "tests"}: misplaced_python.append(relative.as_posix()) @@ -308,7 +309,10 @@ def developer_checks(project_root: Path, settings: MaintenanceSettings) -> Devel if isinstance(node, (ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith("_") ] - if len(public_features) > 12: + # A public type catalog or thin platform binding is naturally wider than an + # implementation module. Flag only genuinely broad modules, not cohesive + # contracts and adapter surfaces. + if len(public_features) > _MAX_PUBLIC_FEATURES_PER_MODULE: crowded_modules.append(relative.as_posix()) return { diff --git a/tests/test_cli.py b/tests/test_cli.py index 493fa058..93bb2ffd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,5 @@ +"""Regression coverage for the public command-line interface.""" + from __future__ import annotations import io @@ -68,6 +70,14 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertTrue(request.performance_check) self.assertEqual(1, request.max_workers) + def test_config_flag_is_accepted_before_or_after_a_subcommand(self) -> None: + """Configuration selection must not depend on flag placement around the action.""" + parser = cli_methods.parser() + expected = Path("custom.yaml") + + self.assertEqual(expected, parser.parse_args(["--config", "custom.yaml", "preflight"]).config) + self.assertEqual(expected, parser.parse_args(["preflight", "--config", "custom.yaml"]).config) + def test_capabilities_default_to_metadata_and_cli_can_block_them(self) -> None: """Declared capabilities run by default and can be disabled per invocation.""" parser = cli_methods.parser() @@ -246,12 +256,16 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( root = Path(temporary) (root / ".git").mkdir() - git = subprocess.CompletedProcess( - ["git", "--version"], - 0, - "git version 2.0\n", - "", - ) + def reachable_git(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + if command == ["git", "--version"]: + return subprocess.CompletedProcess(command, 0, "git version 2.0\n", "") + if command == ["git", "rev-parse", "--is-inside-work-tree"]: + return subprocess.CompletedProcess(command, 0, "true\n", "") + if command == ["git", "remote", "get-url", "origin"]: + return subprocess.CompletedProcess(command, 0, "https://github.com/example/logicytics.git\n", "") + if command == ["git", "ls-remote", "--exit-code", "origin", "HEAD"]: + return subprocess.CompletedProcess(command, 0, "head\tHEAD\n", "") + self.fail(f"unexpected Git command: {command}") output = io.StringIO() with ( @@ -263,7 +277,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( patch.object( process_adapter, process_adapter.run.__name__, - return_value=git, + side_effect=reachable_git, ), patch.object( CLI, @@ -295,6 +309,7 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( payload = json.loads((root / "output" / "logs" / "debug" / "update.json").read_text(encoding="utf-8")) self.assertEqual("debug", payload["launched_action"]) self.assertEqual(321, payload["launched_process_id"]) + self.assertTrue(payload["remote_reachable"]) launch.assert_called_once_with(root, "debug") error_logger = MagicMock() @@ -321,6 +336,57 @@ def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( event_messages = [call.args[1] for call in error_logger.event.call_args_list if len(call.args) > 1] self.assertIn("command_finished", event_messages) + def test_update_refuses_an_unreachable_git_remote(self) -> None: + """A local Git directory is insufficient when its configured remote cannot be reached.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + + def unreachable_git(command: list[str], **_: object) -> subprocess.CompletedProcess[str]: + if command == ["git", "--version"]: + return subprocess.CompletedProcess(command, 0, "git version 2.0\n", "") + if command == ["git", "rev-parse", "--is-inside-work-tree"]: + return subprocess.CompletedProcess(command, 0, "true\n", "") + if command == ["git", "remote", "get-url", "origin"]: + return subprocess.CompletedProcess(command, 0, "https://github.com/example/logicytics.git\n", "") + if command == ["git", "ls-remote", "--exit-code", "origin", "HEAD"]: + return subprocess.CompletedProcess(command, 128, "", "network unavailable") + self.fail(f"unexpected Git command: {command}") + + with ( + patch.object(CLI, CLI.project_root.__name__, return_value=root), + patch.object(process_adapter, process_adapter.run.__name__, side_effect=unreachable_git), + patch("sys.stderr", output), + ): + self.assertEqual(2, main(["update", "--apply"])) + + payload = json.loads((root / "output" / "logs" / "debug" / "update.json").read_text(encoding="utf-8")) + self.assertFalse(payload["remote_reachable"]) + self.assertFalse(payload["applied"]) + self.assertIn("GitHub reachable: no", output.getvalue()) + + def test_update_reports_a_missing_git_executable(self) -> None: + """Maintenance status remains readable when Git is not installed.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + + with ( + patch.object(CLI, CLI.project_root.__name__, return_value=root), + patch.object( + process_adapter, + process_adapter.run.__name__, + side_effect=FileNotFoundError("git executable is unavailable"), + ), + patch("sys.stderr", output), + ): + self.assertEqual(2, main(["update", "--apply"])) + + payload = json.loads((root / "output" / "logs" / "debug" / "update.json").read_text(encoding="utf-8")) + self.assertFalse(payload["git_available"]) + self.assertFalse(payload["remote_reachable"]) + self.assertIn("Git available: no", output.getvalue()) + def test_new_window_launcher_uses_current_interpreter_without_a_shell( self, ) -> None: diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 6f5b8273..a24b8840 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -1,3 +1,5 @@ +"""Regression coverage for maintenance and integrity-manifest behavior.""" + from __future__ import annotations import hashlib @@ -169,6 +171,16 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No """Side actions remain separate from collection and write only their dedicated artifacts.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) + repository = { + "git_available": True, + "git_version": "git version 2.0", + "is_repository": True, + "origin_configured": True, + "remote_reachable": True, + "repository_returncode": 0, + "origin_returncode": 0, + "reachability_returncode": 0, + } (root / "pyproject.toml").write_text( '[project]\nname = "fixture"\nversion = "4.0.0"\n', encoding="utf-8", @@ -179,6 +191,7 @@ def test_dev_writes_explicit_manifest_and_debug_persists_diagnostics(self) -> No ) with ( patch.object(CLI, "project_root", return_value=root), + patch.object(CLI, "repository_status", return_value=repository), patch( "sys.stdout", new_callable=io.StringIO, @@ -215,6 +228,16 @@ def test_dev_writes_manifest_without_mutating_legacy_ini(self) -> None: """The v4 developer action writes its JSON manifest and leaves legacy files untouched.""" with tempfile.TemporaryDirectory() as temporary: root = Path(temporary) + repository = { + "git_available": True, + "git_version": "git version 2.0", + "is_repository": True, + "origin_configured": True, + "remote_reachable": True, + "repository_returncode": 0, + "origin_returncode": 0, + "reachability_returncode": 0, + } (root / "pyproject.toml").write_text( '[project]\nname = "fixture"\nversion = "4.0.0"\n', encoding="utf-8", @@ -232,6 +255,7 @@ def test_dev_writes_manifest_without_mutating_legacy_ini(self) -> None: output = io.StringIO() with ( patch.object(CLI, "project_root", return_value=root), + patch.object(CLI, "repository_status", return_value=repository), patch( "sys.stderr", output, From 8e9e145dc216aa909518d4ebdb6e782ea2e54a59 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 07:18:27 +0300 Subject: [PATCH 276/316] feat(profiles): expand standard system inventory Include routine local configuration, hardware, encryption, storage, registry, and security collectors in the standard profile while retaining sensitive evidence outside it. --- core/encryption/bitlocker_status.py | 2 +- core/encryption/bitlocker_volumes.py | 2 +- core/hardware/battery_status.py | 2 +- core/hardware/display_adapters.py | 2 +- core/hardware/windows_features.py | 2 +- core/network/firewall_profiles.py | 2 +- core/registry/installed_applications.py | 2 +- core/registry/startup_applications.py | 2 +- core/storage/mounted_volumes.py | 2 +- core/storage/physical_disks.py | 2 +- core/storage/volume_details.py | 2 +- core/system/bios_info.py | 2 +- core/system/computer_system.py | 2 +- core/system/defender_status.py | 2 +- core/system/environment_posture.py | 2 +- core/system/group_policy.py | 2 +- core/system/installed_drivers.py | 2 +- core/system/installed_updates.py | 2 +- core/system/operating_system.py | 2 +- core/system/system_details.py | 2 +- core/system/system_diagnostics.py | 2 +- core/system/windows_services.py | 2 +- core/system/wmic_inventory.py | 2 +- logicytics/contracts.py | 12 +++++- logicytics/module/planner.py | 2 +- tests/test_contracts.py | 4 +- tests/test_preflight.py | 4 +- tests/test_shipped_collectors.py | 51 ++++++++++++++++++++++--- 28 files changed, 85 insertions(+), 34 deletions(-) diff --git a/core/encryption/bitlocker_status.py b/core/encryption/bitlocker_status.py index 2f746f60..6a4df4ac 100644 --- a/core/encryption/bitlocker_status.py +++ b/core/encryption/bitlocker_status.py @@ -38,7 +38,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("encryption_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=2 * 1024 * 1024, ) diff --git a/core/encryption/bitlocker_volumes.py b/core/encryption/bitlocker_volumes.py index 7351ecbb..7b825d36 100644 --- a/core/encryption/bitlocker_volumes.py +++ b/core/encryption/bitlocker_volumes.py @@ -52,7 +52,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("encryption_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=512 * 1024, ) diff --git a/core/hardware/battery_status.py b/core/hardware/battery_status.py index 1d403d41..170d5f8f 100644 --- a/core/hardware/battery_status.py +++ b/core/hardware/battery_status.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=128 * 1024, ) diff --git a/core/hardware/display_adapters.py b/core/hardware/display_adapters.py index 7dfbe581..e77df794 100644 --- a/core/hardware/display_adapters.py +++ b/core/hardware/display_adapters.py @@ -34,7 +34,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("hardware_inventory",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=30, maximum_output_bytes=256 * 1024, ) diff --git a/core/hardware/windows_features.py b/core/hardware/windows_features.py index 6273014b..f50de8e4 100644 --- a/core/hardware/windows_features.py +++ b/core/hardware/windows_features.py @@ -39,7 +39,7 @@ def metadata(cls) -> CollectorMetadata: author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=60, maximum_output_bytes=5 * 1024 * 1024, ) diff --git a/core/network/firewall_profiles.py b/core/network/firewall_profiles.py index 6c4d99f4..62730961 100644 --- a/core/network/firewall_profiles.py +++ b/core/network/firewall_profiles.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=256 * 1024, ) diff --git a/core/registry/installed_applications.py b/core/registry/installed_applications.py index 776ba0dc..a0fd392b 100644 --- a/core/registry/installed_applications.py +++ b/core/registry/installed_applications.py @@ -91,7 +91,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, ) diff --git a/core/registry/startup_applications.py b/core/registry/startup_applications.py index e84677af..31d994c6 100644 --- a/core/registry/startup_applications.py +++ b/core/registry/startup_applications.py @@ -77,7 +77,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.REGISTRY_READ,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=30, maximum_output_bytes=512 * 1024, ) diff --git a/core/storage/mounted_volumes.py b/core/storage/mounted_volumes.py index 27b52e9e..5432afe1 100644 --- a/core/storage/mounted_volumes.py +++ b/core/storage/mounted_volumes.py @@ -38,7 +38,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=30, maximum_output_bytes=512 * 1024, ) diff --git a/core/storage/physical_disks.py b/core/storage/physical_disks.py index cf3b27f6..30a8153d 100644 --- a/core/storage/physical_disks.py +++ b/core/storage/physical_disks.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=512 * 1024, ) diff --git a/core/storage/volume_details.py b/core/storage/volume_details.py index 14ba3d79..28697b7c 100644 --- a/core/storage/volume_details.py +++ b/core/storage/volume_details.py @@ -98,7 +98,7 @@ def metadata(cls) -> CollectorMetadata: description="Exports mounted drive type, filesystem, label, and capacity metadata.", author="Logicytics", supported_platforms=("win32",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=15, capabilities=(), maximum_output_bytes=128 * 1024, diff --git a/core/system/bios_info.py b/core/system/bios_info.py index fe5981c1..85dc7fee 100644 --- a/core/system/bios_info.py +++ b/core/system/bios_info.py @@ -71,7 +71,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=128 * 1024, ) diff --git a/core/system/computer_system.py b/core/system/computer_system.py index db5e49cc..dfb08a21 100644 --- a/core/system/computer_system.py +++ b/core/system/computer_system.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=128 * 1024, ) diff --git a/core/system/defender_status.py b/core/system/defender_status.py index d734098d..3235a4cd 100644 --- a/core/system/defender_status.py +++ b/core/system/defender_status.py @@ -45,7 +45,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("security_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=30, maximum_output_bytes=128 * 1024, ) diff --git a/core/system/environment_posture.py b/core/system/environment_posture.py index 5b9dc7fb..07989c2a 100644 --- a/core/system/environment_posture.py +++ b/core/system/environment_posture.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=256 * 1024, ) diff --git a/core/system/group_policy.py b/core/system/group_policy.py index 0d25ac9c..45ebe441 100644 --- a/core/system/group_policy.py +++ b/core/system/group_policy.py @@ -37,7 +37,7 @@ def metadata(cls) -> CollectorMetadata: author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=2 * 1024 * 1024, ) diff --git a/core/system/installed_drivers.py b/core/system/installed_drivers.py index e643843a..abe6d2ea 100644 --- a/core/system/installed_drivers.py +++ b/core/system/installed_drivers.py @@ -37,7 +37,7 @@ def metadata(cls) -> CollectorMetadata: author="Logicytics", supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=30, maximum_output_bytes=4 * 1024 * 1024, ) diff --git a/core/system/installed_updates.py b/core/system/installed_updates.py index 944b509f..ae2fecd9 100644 --- a/core/system/installed_updates.py +++ b/core/system/installed_updates.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=512 * 1024, ) diff --git a/core/system/operating_system.py b/core/system/operating_system.py index 0d6d71b5..31a6dc41 100644 --- a/core/system/operating_system.py +++ b/core/system/operating_system.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=128 * 1024, ) diff --git a/core/system/system_details.py b/core/system/system_details.py index a17db847..e7526d13 100644 --- a/core/system/system_details.py +++ b/core/system/system_details.py @@ -38,7 +38,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=2 * 1024 * 1024, ) diff --git a/core/system/system_diagnostics.py b/core/system/system_diagnostics.py index 86094345..57f8645c 100644 --- a/core/system/system_diagnostics.py +++ b/core/system/system_diagnostics.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=45, maximum_output_bytes=256 * 1024, ) diff --git a/core/system/windows_services.py b/core/system/windows_services.py index 86b9de03..f18446e5 100644 --- a/core/system/windows_services.py +++ b/core/system/windows_services.py @@ -40,7 +40,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=60, maximum_output_bytes=4 * 1024 * 1024, ) diff --git a/core/system/wmic_inventory.py b/core/system/wmic_inventory.py index 54b0ddc3..43ab4353 100644 --- a/core/system/wmic_inventory.py +++ b/core/system/wmic_inventory.py @@ -32,7 +32,7 @@ def metadata(cls) -> CollectorMetadata: supported_platforms=("win32",), capabilities=(Capability.SUBPROCESS,), sensitive_data_categories=("system_configuration",), - default_profiles=("deep",), + default_profiles=("standard", "deep"), timeout_seconds=30, maximum_output_bytes=256 * 1024, ) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index dae2ab0a..f9c1537d 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -210,8 +210,16 @@ def __post_init__(self) -> None: raise ValueError("metadata output_media_types must be a non-empty tuple of MIME types") if len(set(self.output_media_types)) != len(self.output_media_types): raise ValueError("metadata output_media_types must not contain duplicates") - if self.sensitive_data_categories and {"standard", "minimal"}.intersection(self.default_profiles): - raise ValueError("sensitive collectors must not belong to standard or minimal profiles") + standard_categories = { + "encryption_configuration", + "hardware_inventory", + "security_configuration", + "system_configuration", + } + if "minimal" in self.default_profiles and self.sensitive_data_categories: + raise ValueError("sensitive collectors must not belong to the minimal profile") + if "standard" in self.default_profiles and not set(self.sensitive_data_categories).issubset(standard_categories): + raise ValueError("standard collectors may contain only routine local configuration categories") if not isinstance(self.capabilities, tuple) or not all(isinstance(capability, Capability) for capability in self.capabilities): raise ValueError("metadata capabilities must be a tuple of Capability values") if not isinstance(self.privilege_level, PrivilegeLevel): diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index c3cfbda0..bf5cd1d7 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -16,7 +16,7 @@ BUILTIN_PROFILES = MappingProxyType( { "minimal": "Essential local system, memory, and storage inventory only.", - "standard": "Shipped core collectors explicitly declaring standard membership.", + "standard": "Routine local system and security configuration inventory.", "deep": "Extended declared inventory, subject to declared-capability blocking policy.", "offline": "Declared local-only inventory with network and packet access prohibited.", } diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 43125565..c240de0c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -1,3 +1,5 @@ +"""Regression coverage for collector contracts and metadata safeguards.""" + from __future__ import annotations import unittest @@ -60,7 +62,7 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: CollectorMetadata(**{**common, "resource_class": "disk_heavy"}) with self.assertRaisesRegex(ValueError, "ResourceClass"): CollectorMetadata.from_dict({**CollectorMetadata(**common).to_dict(), "resource_class": "unknown"}) - with self.assertRaisesRegex(ValueError, "sensitive collectors"): + with self.assertRaisesRegex(ValueError, "routine local configuration"): CollectorMetadata(**{**common, "sensitive_data_categories": ("credentials",)}) for retries in (-1, 4, True): with self.subTest(maximum_retries=retries): diff --git a/tests/test_preflight.py b/tests/test_preflight.py index add53e43..20c82104 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -1,3 +1,5 @@ +"""Regression coverage for static collector preflight validation.""" + from __future__ import annotations import io @@ -206,7 +208,7 @@ def test_preflight_rejects_sensitive_default_profile_collector(self) -> None: ) report = preflight(root) self.assertEqual(1, len(report.invalid)) - self.assertIn("sensitive collectors", report.invalid[0].runtime_error or "") + self.assertIn("routine local configuration", report.invalid[0].runtime_error or "") with self.assertRaises(PreflightError): build_plan(report, RunRequest()) diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 7f0fef9b..281eb772 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -792,8 +792,38 @@ def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( len(memberships["minimal"]), ) self.assertEqual( - 6, - len(memberships["standard"]), + { + "core.encryption.bitlocker_status", + "core.encryption.bitlocker_volumes", + "core.hardware.battery_status", + "core.hardware.display_adapters", + "core.hardware.windows_features", + "core.memory.memory_snapshot", + "core.network.firewall_profiles", + "core.network.network_adapters", + "core.network.network_identity", + "core.process.running_processes", + "core.registry.installed_applications", + "core.registry.startup_applications", + "core.storage.logical_drives", + "core.storage.mounted_volumes", + "core.storage.physical_disks", + "core.storage.volume_details", + "core.system.bios_info", + "core.system.computer_system", + "core.system.defender_status", + "core.system.environment_posture", + "core.system.group_policy", + "core.system.installed_drivers", + "core.system.installed_updates", + "core.system.operating_system", + "core.system.system_details", + "core.system.system_diagnostics", + "core.system.system_info", + "core.system.windows_services", + "core.system.wmic_inventory", + }, + memberships["standard"], ) self.assertEqual( {item.id for item in metadata}, @@ -811,11 +841,20 @@ def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( memberships["minimal"], memberships["offline"], ) - self.assertLessEqual( - sum(item.timeout_seconds for item in metadata if item.id in memberships["standard"]), - 90, + self.assertTrue( + all( + set(item.sensitive_data_categories).issubset( + { + "encryption_configuration", + "hardware_inventory", + "security_configuration", + "system_configuration", + } + ) + for item in metadata + if item.id in memberships["standard"] + ) ) - self.assertTrue(all(not item.sensitive_data_categories for item in metadata if item.id in memberships["standard"])) if __name__ == "__main__": From 2cbc0685361b7e78899bb4e5f221837d38ce2c76 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 07:18:42 +0300 Subject: [PATCH 277/316] style(tests): document test module responsibilities --- tests/fixtures/__init__.py | 1 + tests/test_artifact_writer.py | 2 ++ tests/test_configuration.py | 2 ++ tests/test_interaction.py | 2 ++ tests/test_logging.py | 2 ++ tests/test_mods.py | 2 ++ tests/test_packaging.py | 2 ++ tests/test_planning.py | 2 ++ tests/test_public_api.py | 2 ++ tests/test_scheduler.py | 2 ++ tests/test_test_runner_cli.py | 2 ++ tests/test_worker_security.py | 2 ++ 12 files changed, 23 insertions(+) diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py index e69de29b..4b12e6a0 100644 --- a/tests/fixtures/__init__.py +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Shared test fixtures for Logicytics regression coverage.""" diff --git a/tests/test_artifact_writer.py b/tests/test_artifact_writer.py index 64ee8fad..abb9c8e0 100644 --- a/tests/test_artifact_writer.py +++ b/tests/test_artifact_writer.py @@ -1,3 +1,5 @@ +"""Regression coverage for artifact writing and output validation.""" + from __future__ import annotations import tempfile diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 16fc4051..5c02ff59 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1,3 +1,5 @@ +"""Regression coverage for application configuration parsing and validation.""" + from __future__ import annotations import json diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 758235e1..e20322f7 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -1,3 +1,5 @@ +"""Regression coverage for interaction history and rendered analysis artifacts.""" + from __future__ import annotations import io diff --git a/tests/test_logging.py b/tests/test_logging.py index b4bf6ff7..bf9a41e7 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,3 +1,5 @@ +"""Regression coverage for structured console and file logging.""" + from __future__ import annotations import ast diff --git a/tests/test_mods.py b/tests/test_mods.py index e4f9c9a8..ad6c08a0 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -1,3 +1,5 @@ +"""Regression coverage for optional mod discovery and execution.""" + from __future__ import annotations import json diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 733f5fdb..82fead23 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -1,3 +1,5 @@ +"""Regression coverage for deterministic run-package generation.""" + from __future__ import annotations import hashlib diff --git a/tests/test_planning.py b/tests/test_planning.py index 7a90860a..bc32e5a3 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -1,3 +1,5 @@ +"""Regression coverage for collector planning and profile selection.""" + from __future__ import annotations import io diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 1bd3e297..1c008c57 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -1,3 +1,5 @@ +"""Regression coverage for the supported package-level public API.""" + from __future__ import annotations import json diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index caf450fb..2cb65e7a 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1,3 +1,5 @@ +"""Regression coverage for collector scheduling and resource coordination.""" + from __future__ import annotations import tempfile diff --git a/tests/test_test_runner_cli.py b/tests/test_test_runner_cli.py index 520e52ad..a02dd5ba 100644 --- a/tests/test_test_runner_cli.py +++ b/tests/test_test_runner_cli.py @@ -1,3 +1,5 @@ +"""Regression coverage for the integrated command-line test runner.""" + from __future__ import annotations import io diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index d0cc9eb7..7b4624e8 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -1,3 +1,5 @@ +"""Regression coverage for worker process security boundaries.""" + from __future__ import annotations import sys From 893641c1d5d79da55d122054acceacf3e519d533 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 17:10:24 +0300 Subject: [PATCH 278/316] fix(presentation): color interactive banners Apply the shared cyan, purple, and warning palette to startup banners, warning alerts, and titled sections when attached to a TTY. Preserve plain ASCII output for redirected streams and disabled color settings. Signed-off-by: DefinetlyNotAI --- logicytics/module/logging.py | 5 +-- logicytics/module/presentation.py | 54 ++++++++++++++++++++++++------- logicytics/virtual_environment.py | 2 +- tests/test_logging.py | 36 +++++++++++++++++++++ tests/test_terminal.py | 16 +++++++++ 5 files changed, 98 insertions(+), 15 deletions(-) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 54bee0a7..caff7bed 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -45,12 +45,12 @@ } _LEVEL_PRESENTATION = { "DEBUG": ("\u00b7", "\033[90m", "\033[90m"), - "INTERNAL": ("\u00b7", "\033[95m", "\033[95m"), + "INTERNAL": ("\u00b7", "\033[90m", "\033[90m"), "INFO": ("\u25cf", "\033[96m", "\033[97m"), "WARNING": ("!", "\033[93m", "\033[93m"), "ERROR": ("\u00d7", "\033[91m", "\033[91m"), "EXCEPTION": ("\u00d7", "\033[91m", "\033[91m"), - "CRITICAL": ("\u00d7", "\033[91m", "\033[91m"), + "CRITICAL": ("\u00d7", "\033[31m", "\033[31m"), } _DETAIL_MARKER_COLOR = "\033[95m" _RESET = "\033[0m" @@ -394,6 +394,7 @@ def render_alert( lines, message_lines=cls._message_lines, width=cls._console_width, + color_enabled=console.isatty(), ) def box(self, title: str, lines: Iterable[str]) -> None: diff --git a/logicytics/module/presentation.py b/logicytics/module/presentation.py index e9fe7219..a21274b8 100644 --- a/logicytics/module/presentation.py +++ b/logicytics/module/presentation.py @@ -13,6 +13,8 @@ _MIN_CONSOLE_WIDTH = 60 _RIGHT_EDGE_MARGIN = 4 _DETAIL_MARKER_COLOR = "\033[95m" +_INFO_COLOR = "\033[96m" +_WARNING_COLOR = "\033[93m" _BOLD = "\033[1m" _RESET = "\033[0m" @@ -49,12 +51,16 @@ def render_banner(console: TextIO, *, width: ConsoleWidth = terminal_width) -> N available = max(width(), _MIN_CONSOLE_WIDTH) title = "LOGICYTICS" subtitle = "Local evidence collection framework" - rows = ( - title, - "-" * available, - subtitle, - "", - ) + rule = "-" * available + if console.isatty(): + rows = ( + f"\n{_INFO_COLOR}{_BOLD}{title}{_RESET}", + f"{_INFO_COLOR}{rule}{_RESET}", + f"\033[97m{subtitle}{_RESET}", + "\n", + ) + else: + rows = (title, rule, subtitle, "") console.write("\n".join(rows)) console.flush() @@ -77,11 +83,15 @@ def render_section( *, message_lines: MessageLines = _plain_message_lines, width: ConsoleWidth = console_width, - color_enabled: bool = False, + color_enabled: bool | None = None, ) -> None: """Render the shared indented, wrapped console section presentation.""" - rows = [_heading(title, width=width), ""] - use_color = color_enabled and console.isatty() + use_color = (console.isatty() if color_enabled is None else color_enabled) and console.isatty() + heading = _heading(title, width=width) + if use_color: + heading_title, heading_rule = heading.split("\n", 1) + heading = f"{_INFO_COLOR}{_BOLD}{heading_title}{_RESET}\n{_INFO_COLOR}{heading_rule}{_RESET}" + rows = [heading, ""] for line in lines: for raw_row in line.splitlines() or [""]: safe_row = redact_text(raw_row) @@ -115,11 +125,20 @@ def render_alert( *, message_lines: MessageLines = _plain_message_lines, width: ConsoleWidth = console_width, + color_enabled: bool | None = None, ) -> None: """Render a severity-marked alert with a clean title and ASCII rule.""" available = max(width(), _MIN_CONSOLE_WIDTH) safe_title = redact_text(title).strip() - rows = [safe_title, "-" * available, ""] + use_color = (console.isatty() if color_enabled is None else color_enabled) and console.isatty() + if use_color: + rows = [ + f"{_WARNING_COLOR}{_BOLD}{safe_title}{_RESET}", + f"{_WARNING_COLOR}{'-' * available}{_RESET}", + "", + ] + else: + rows = [safe_title, "-" * available, ""] for line_index, line in enumerate(lines): marker = "!" if line_index == 0 else ">" prefix = f" {marker} " @@ -132,7 +151,18 @@ def render_alert( break_long_words=True, break_on_hyphens=False, ) or [""] - rows.append(prefix + wrapped[0]) - rows.extend(f"{continuation_prefix}{row}" for row in wrapped[1:]) + first_row = prefix + wrapped[0] + if use_color: + marker_prefix = ( + f"{_WARNING_COLOR}{_BOLD}{prefix}{_RESET}" + if marker == "!" + else f" {_DETAIL_MARKER_COLOR}{_BOLD}>{_RESET} " + ) + first_row = f"{marker_prefix}{_WARNING_COLOR}{wrapped[0]}{_RESET}" + rows.append(first_row) + rows.extend( + f"{_WARNING_COLOR}{continuation_prefix}{row}{_RESET}" if use_color else f"{continuation_prefix}{row}" + for row in wrapped[1:] + ) console.write("\n".join(rows) + "\n") console.flush() diff --git a/logicytics/virtual_environment.py b/logicytics/virtual_environment.py index 201a4d45..23607323 100644 --- a/logicytics/virtual_environment.py +++ b/logicytics/virtual_environment.py @@ -27,7 +27,7 @@ def virtual_environment_error(root: Path) -> tuple[str, str]: ) return ( "Logicytics must run inside a virtual environment.", - "Next step: run python -m logicytics.cli.installer first.", + "Next step: python -m logicytics.cli.installer", ) diff --git a/tests/test_logging.py b/tests/test_logging.py index bf9a41e7..d9c8dd79 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -206,6 +206,42 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self with self.subTest(logging=invalid_logging), self.assertRaises(PlanError): load_config(root, config_path) + def test_severity_text_colors_keep_purple_exclusive_to_detail_markers(self) -> None: + """Severity text uses its own palette; purple is reserved for indented markers.""" + + class TerminalBuffer(io.StringIO): + def isatty(self) -> bool: + return True + + expected_colors = { + "DEBUG": "\033[90m", + "INTERNAL": "\033[90m", + "INFO": "\033[97m", + "WARNING": "\033[93m", + "ERROR": "\033[91m", + "EXCEPTION": "\033[91m", + "CRITICAL": "\033[31m", + } + with tempfile.TemporaryDirectory() as temporary: + console = TerminalBuffer() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(level="DEBUG", file_enabled=False, color_enabled=True), + console=console, + ) + for level, text_color in expected_colors.items(): + console.seek(0) + console.truncate(0) + logger.event(level, "plain message") + rendered = console.getvalue() + self.assertIn(f"{text_color}\033[1m", rendered) + self.assertNotIn("\033[95m", rendered) + + logger.event("INFO", "message with detail", detail="value") + rendered = console.getvalue() + self.assertIn("\033[95m\033[1m>\033[0m", rendered) + self.assertIn("\033[97m\033[1m Detail: value", rendered) + def test_application_logging_wraps_console_rows_and_falls_back_to_ascii(self) -> None: """Compact console records stay aligned on terminals without Unicode support.""" diff --git a/tests/test_terminal.py b/tests/test_terminal.py index 27dbdbd9..842c4236 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -63,6 +63,22 @@ def test_banner_is_full_width_and_strictly_ascii(self) -> None: self.assertNotIn("|", console.getvalue()) self.assertTrue(all(ord(character) < 128 for row in rows for character in row)) + def test_interactive_banners_use_severity_colors(self) -> None: + """Interactive startup and warning banners retain visible color cues.""" + console = _InteractiveBuffer() + render_banner(console, width=lambda: 80) + self.assertIn("\033[96m", console.getvalue()) + self.assertIn("\033[97m", console.getvalue()) + self.assertNotIn("\033[95m", console.getvalue()) + + console = _InteractiveBuffer() + from logicytics.module.logging import ApplicationLogger + + ApplicationLogger.render_alert(console, "Startup warning", ("Activate the environment", "Use the installer")) + rendered = console.getvalue() + self.assertIn("\033[93m", rendered) + self.assertIn("\033[95m", rendered) + def test_interactive_lifecycle_clears_once_and_ends_with_a_newline(self) -> None: """The outer lifecycle performs real clearing once and leaves a clean prompt line.""" output = _InteractiveBuffer() From 603f69d9fc8ccedaccf189b1b0961cc9e5c13ff3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 17:35:17 +0300 Subject: [PATCH 279/316] fix(cli): harden cancellation and preflight progress --- README.md | 10 +++++++++ logicytics/cli/__init__.py | 20 +++++++++++------- logicytics/cli/commands.py | 38 +++++++++++++++++++++++++++++++--- logicytics/cli/installer.py | 8 +++++-- logicytics/cli/tests.py | 10 +++++++-- logicytics/contracts.py | 2 +- logicytics/module/discovery.py | 9 ++++++++ logicytics/module/logging.py | 27 +++++++++++++++++++++--- tests/test_cli.py | 26 +++++++++++++++++++++++ tests/test_logging.py | 22 ++++++++++++++++++++ tests/test_preflight.py | 13 ++++++++++++ 11 files changed, 167 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index bc94b35a..b2190a7c 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,14 @@ The installer is the only Logicytics command designed to run outside a virtual e python -m logicytics.cli.installer .\.venv\Scripts\Activate.ps1 python -m logicytics preflight +python -m logicytics preflight --invalidate-cache ``` When preflight is clean, plan before collecting: ```powershell python -m logicytics plan --profile standard +python -m logicytics plan --profile standard --invalidate-cache python -m logicytics run --profile standard --acknowledge-authorization ``` @@ -166,6 +168,14 @@ python -m logicytics preflight --mods It returns 0 when there are no blocking invalid collectors and 2 when a blocking validation failure exists. It does not collect evidence. +Preflight caches only successful runtime metadata probes; the deterministic +plan itself is rebuilt each time. The cache is stored outside the repository at +`%TEMP%\logicytics-preflight-cache\.json` on Windows (or the +platform temporary directory equivalent). Entries are invalidated by source, +interpreter, contract, or configuration changes. Use `--invalidate-cache` on +`preflight` or `plan` to discard the project cache and force every runtime +metadata probe to run again. + ## Planning Planning resolves profile membership, explicit selections, dependencies, diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 7dccf879..7a5835e5 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -21,13 +21,19 @@ def _project_root() -> Path: def main(argv: list[str] | None = None) -> int: """Guard the interpreter before loading the full CLI implementation.""" - with terminal_lifecycle(): - if not is_running_in_virtual_environment(): - render_virtual_environment_error(sys.stderr, _project_root()) - return 2 - from logicytics.cli.commands import main as command_main - - return command_main(argv) + try: + with terminal_lifecycle(): + if not is_running_in_virtual_environment(): + render_virtual_environment_error(sys.stderr, _project_root()) + return 2 + from logicytics.cli.commands import main as command_main + + return command_main(argv) + except KeyboardInterrupt: + from logicytics.module.logging import ApplicationLogger + + ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + return 130 def __getattr__(name: str) -> object: diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 5bf41b0b..38413c1f 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -278,6 +278,12 @@ def parser() -> argparse.ArgumentParser: choices=tuple(BUILTIN_PROFILES), help="Named built-in collector membership and access policy.", ) + if command in {"preflight", "plan"}: + subparser.add_argument( + "--invalidate-cache", + action="store_true", + help="Discard cached preflight metadata before validation.", + ) subparser.add_argument( "--include", action="append", @@ -678,7 +684,11 @@ def main(argv: list[str] | None = None) -> int: ) return 2 cli_parser = cli_methods.parser() - arguments = cli_parser.parse_args(argv) + try: + arguments = cli_parser.parse_args(argv) + except KeyboardInterrupt: + ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + return 130 standalone_actions = sum( bool(value) @@ -763,12 +773,14 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str application_logger.event( "INFO", "preflight_progress", + console=False, source="logicytics.cli", phase="validating collectors", checked=checked, total=total, current=current, ) + application_logger.progress("Preflight", checked, total, current) last_preflight_progress_at = now if arguments.command == "match": @@ -896,6 +908,7 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str root, configuration_hash=configuration.fingerprint(), progress=report_preflight_progress, + invalidate_cache=getattr(arguments, "invalidate_cache", False), ) application_logger.event( "INFO", @@ -1184,6 +1197,21 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str collectors=len(outcome.manifest.collectors), ) + except KeyboardInterrupt: + if application_logger is not None: + with contextlib.suppress(OSError): + application_logger.event( + "WARNING", + "command_cancelled", + source="logicytics.cli", + command=str(arguments.command), + exit_code=130, + console=False, + ) + application_logger.box("Command cancelled", ("Interrupted by user.",)) + else: + ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + return 130 except ( LogicyticsError, OSError, @@ -1225,5 +1253,9 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str cli_methods = CLI() if __name__ == "__main__": - with terminal_lifecycle(): - raise SystemExit(main()) + try: + with terminal_lifecycle(): + raise SystemExit(main()) + except KeyboardInterrupt: + ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + raise SystemExit(130) diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 0daf4962..95b16ef5 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -44,5 +44,9 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - with terminal_lifecycle(): - raise SystemExit(main()) + try: + with terminal_lifecycle(): + raise SystemExit(main()) + except KeyboardInterrupt: + ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + raise SystemExit(130) diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index c0277ef0..f1e55a4d 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -70,5 +70,11 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - with terminal_lifecycle(): - raise SystemExit(main()) + try: + with terminal_lifecycle(): + raise SystemExit(main()) + except KeyboardInterrupt: + from logicytics.module.logging import ApplicationLogger + + ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + raise SystemExit(130) diff --git a/logicytics/contracts.py b/logicytics/contracts.py index f9c1537d..ebd350b5 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -569,7 +569,7 @@ class EventLogger(ABC): """A structured event sink scoped to one run or collector worker.""" @abstractmethod - def event(self, level: str, message: str, **fields: float | str) -> None: + def event(self, level: str, message: str, *, console: bool = True, **fields: float | str) -> None: """Record one machine-readable event without exposing raw evidence.""" diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index 9788432b..c0ac47ae 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -832,8 +832,17 @@ def preflight( *, configuration_hash: str = "unconfigured", progress: PreflightProgress | None = None, + invalidate_cache: bool = False, ) -> PreflightReport: """Perform static checks then a short-lived isolated metadata probe.""" + if invalidate_cache: + cache_path = _cache_path(project_root) + try: + cache_path.unlink() + except FileNotFoundError: + pass + except OSError as error: + raise OSError(f"unable to invalidate preflight cache: {error}") from error candidates = list(discover(project_root)) cached = _load_cache(project_root, configuration_hash) total = len(candidates) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index caff7bed..6485d916 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -304,7 +304,7 @@ def _console_value(cls, value: object) -> str: return cls._console_value(parsed) return str(value) - def event(self, level: str, message: str, **fields: float | str) -> None: + def event(self, level: str, message: str, *, console: bool = True, **fields: float | str) -> None: """Dispatch one typed, redacted event to configured console and file sinks.""" normalized = _normalize_level(level) minimum_level = _normalize_level(self.settings.level) @@ -330,7 +330,7 @@ def event(self, level: str, message: str, **fields: float | str) -> None: with self.path.open("a", encoding="utf-8") as stream: stream.write("\n".join(rows) + "\n") self._truncate_file() - if self.settings.console_enabled: + if self.settings.console_enabled and console: self._render_step(message) marker, marker_color, text_color = _LEVEL_PRESENTATION[normalized] if not self._supports_unicode(): @@ -350,6 +350,27 @@ def event(self, level: str, message: str, **fields: float | str) -> None: self.console.write("\n".join(console_rows) + "\n") self.console.flush() + def progress(self, label: str, checked: int, total: int, current: str = "") -> None: + """Render one in-place dependency-free progress bar on interactive consoles.""" + if not self.settings.console_enabled or not self.console.isatty(): + return + bounded_total = max(total, 1) + bounded_checked = min(max(checked, 0), bounded_total) + available = max(self._console_width() - len(label) - len(str(bounded_total)) * 2 - 12, 16) + filled = int(available * bounded_checked / bounded_total) + bar = "=" * filled + (">" if filled < available else "") + bar = f"{bar:<{available}}" + suffix = f" {current}" if current else "" + rendered = f"{label} [{bar}] {bounded_checked}/{bounded_total}{suffix}" + rendered = rendered[: self._console_width()] + with self._lock: + if self.settings.color_enabled: + rendered = f"\033[96m\033[1m{rendered}\033[0m" + self.console.write(f"\r\033[2K{rendered}") + if bounded_checked >= bounded_total: + self.console.write("\r\033[2K") + self.console.flush() + def raw(self, message: str, *, end: str = "\n") -> None: """Write redacted console-only presentation that never pollutes the event log.""" if end not in {"", "\n"}: @@ -468,7 +489,7 @@ def __init__(self, path: Path, *, run_id: str, collector_id: str | None = None) self._event_lock = RLock() self.path.parent.mkdir(parents=True, exist_ok=True) - def event(self, level: str, message: str, **fields: float | str) -> None: + def event(self, level: str, message: str, *, console: bool = True, **fields: float | str) -> None: """Write a timestamped, structured event without relying on global handlers.""" normalized = _normalize_level(level) payload: dict[str, object] = { diff --git a/tests/test_cli.py b/tests/test_cli.py index 93bb2ffd..1d033ca1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -14,6 +14,7 @@ from logicytics.cli import CLI, cli_methods, main from logicytics.contracts import Capability from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix +from logicytics.module.configuration import default_config from logicytics.platform_adapters import process_adapter @@ -78,6 +79,31 @@ def test_config_flag_is_accepted_before_or_after_a_subcommand(self) -> None: self.assertEqual(expected, parser.parse_args(["--config", "custom.yaml", "preflight"]).config) self.assertEqual(expected, parser.parse_args(["preflight", "--config", "custom.yaml"]).config) + def test_preflight_and_plan_expose_cache_invalidation(self) -> None: + """Validation-heavy commands can explicitly discard reusable probe metadata.""" + parser = cli_methods.parser() + + self.assertTrue(parser.parse_args(["preflight", "--invalidate-cache"]).invalidate_cache) + self.assertTrue(parser.parse_args(["plan", "--invalidate-cache"]).invalidate_cache) + + def test_ctrl_c_during_command_is_rendered_without_a_traceback(self) -> None: + """A user interrupt returns 130 with a concise cancellation section.""" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + output = io.StringIO() + with ( + patch.object(CLI, CLI.project_root.__name__, return_value=root), + patch("logicytics.cli.commands.load_config", return_value=default_config(root)), + patch("logicytics.cli.commands.preflight", side_effect=KeyboardInterrupt), + patch("sys.stderr", output), + ): + self.assertEqual(130, main(["preflight"])) + + rendered = output.getvalue() + self.assertIn("Command cancelled", rendered) + self.assertIn("Interrupted by user.", rendered) + self.assertNotIn("Traceback", rendered) + def test_capabilities_default_to_metadata_and_cli_can_block_them(self) -> None: """Declared capabilities run by default and can be disabled per invocation.""" parser = cli_methods.parser() diff --git a/tests/test_logging.py b/tests/test_logging.py index d9c8dd79..111410e9 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -271,6 +271,28 @@ def isatty(self) -> bool: self.assertNotIn("\u25cf", console.getvalue()) self.assertNotIn("\u256d", console.getvalue()) + def test_progress_bar_updates_in_place_and_clears_at_completion(self) -> None: + """Interactive progress uses one dependency-free carriage-return bar.""" + + class TerminalBuffer(io.StringIO): + def isatty(self) -> bool: + return True + + with tempfile.TemporaryDirectory() as temporary: + console = TerminalBuffer() + logger = ApplicationLogger( + Path(temporary) / "Logicytics.log", + LoggingSettings(file_enabled=False, color_enabled=False), + console=console, + ) + logger.progress("Preflight", 1, 4, "core.system.info") + logger.progress("Preflight", 4, 4, "core.system.info") + + rendered = console.getvalue() + self.assertEqual(3, rendered.count("\r\033[2K")) + self.assertIn("Preflight [", rendered) + self.assertTrue(rendered.endswith("\r\033[2K")) + def test_application_logging_humanizes_structured_values_and_lowercase_settings(self) -> None: """Human sinks normalize structured messages, collections, binary values, and levels.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 20c82104..3ae7f9f6 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -121,6 +121,19 @@ def test_preflight_cache_requires_source_interpreter_contract_and_configuration_ self.assertEqual(1, len(invalidated.valid), invalidated.invalid) self.assertEqual(1, probe.call_count) + with patch.object( + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, + ) as probe: + explicitly_invalidated = preflight( + root, + configuration_hash="configuration-b", + invalidate_cache=True, + ) + self.assertEqual(1, len(explicitly_invalidated.valid), explicitly_invalidated.invalid) + self.assertEqual(1, probe.call_count) + def test_preflight_reports_collector_progress_before_and_after_validation(self) -> None: """Long-running isolated probes expose their current collector to callers.""" with tempfile.TemporaryDirectory() as temporary: From c249ecac6f36c7347d96260eff983c34e8af5f17 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 17:52:30 +0300 Subject: [PATCH 280/316] fix(cli): mark interrupted preflight progress as incomplete also added newlines to not make it look unprofessional when interrupts occur Signed-off-by: DefinetlyNotAI --- logicytics/cli/__init__.py | 1 + logicytics/cli/commands.py | 40 ++++++++++++++++++++++++++---------- logicytics/cli/installer.py | 1 + logicytics/cli/tests.py | 1 + logicytics/module/logging.py | 24 +++++++++++++++++++++- tests/test_cli.py | 2 +- 6 files changed, 56 insertions(+), 13 deletions(-) diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 7a5835e5..6f28110e 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -32,6 +32,7 @@ def main(argv: list[str] | None = None) -> int: except KeyboardInterrupt: from logicytics.module.logging import ApplicationLogger + print("\n") ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) return 130 diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 38413c1f..9df2851a 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -7,8 +7,8 @@ import importlib.util import json import os -import subprocess import platform +import subprocess import sys from pathlib import Path from time import perf_counter @@ -687,6 +687,7 @@ def main(argv: list[str] | None = None) -> int: try: arguments = cli_parser.parse_args(argv) except KeyboardInterrupt: + print("\n") ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) return 130 @@ -712,10 +713,6 @@ def main(argv: list[str] | None = None) -> int: elif arguments.modes: arguments.command = "modes" - if arguments.command is None: - cli_parser.print_help() - return 0 - root = cli_methods.project_root() application_logger = None command_started_at: float | None = None @@ -767,7 +764,9 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str nonlocal last_preflight_progress_at now = perf_counter() should_report = ( - last_preflight_progress_at == 0.0 or now - last_preflight_progress_at >= 1.0 or (phase == "checked" and checked == total) + last_preflight_progress_at == 0.0 or + now - last_preflight_progress_at >= 1.0 or + (phase == "checked" and checked == total) ) if should_report: application_logger.event( @@ -780,7 +779,9 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str total=total, current=current, ) - application_logger.progress("Preflight", checked, total, current) + application_logger.progress( + "Preflight", checked, total, current + ) last_preflight_progress_at = now if arguments.command == "match": @@ -1177,9 +1178,11 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str if outcome.manifest.package and "path" in outcome.manifest.package: package_sha_path = outcome.manifest.package.get("sha256_path", "unavailable") - result_lines.extend((f"Package: {outcome.manifest.package['path']}\nSHA-256: {package_sha_path}").splitlines()) + result_lines.extend( + f"Package: {outcome.manifest.package['path']}\nSHA-256: {package_sha_path}".splitlines() + ) - result_lines.extend((f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}").splitlines()) + result_lines.extend(f"Run: {outcome.manifest_path}\nStatus: {outcome.manifest.status.value}".splitlines()) application_logger.box("Collection result", result_lines) exit_code = 0 if outcome.manifest.status.value == "succeeded" else 1 @@ -1199,6 +1202,9 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str except KeyboardInterrupt: if application_logger is not None: + with contextlib.suppress(OSError): + application_logger.incomplete_progress("Preflight") + with contextlib.suppress(OSError): application_logger.event( "WARNING", @@ -1208,9 +1214,20 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str exit_code=130, console=False, ) - application_logger.box("Command cancelled", ("Interrupted by user.",)) + + application_logger.box( + "Command cancelled", + ("Interrupted by user.",), + ) + else: - ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) + print("\r\033[2KIncomplete", flush=True) + ApplicationLogger.render_section( + sys.stderr, + "Command cancelled", + ("Interrupted by user.",), + ) + return 130 except ( LogicyticsError, @@ -1257,5 +1274,6 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str with terminal_lifecycle(): raise SystemExit(main()) except KeyboardInterrupt: + print("\n") ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) raise SystemExit(130) diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 95b16ef5..88c72173 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -48,5 +48,6 @@ def main(argv: list[str] | None = None) -> int: with terminal_lifecycle(): raise SystemExit(main()) except KeyboardInterrupt: + print("\n") ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) raise SystemExit(130) diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index f1e55a4d..9a68398a 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -76,5 +76,6 @@ def main(argv: list[str] | None = None) -> int: except KeyboardInterrupt: from logicytics.module.logging import ApplicationLogger + print("\n") ApplicationLogger.render_section(sys.stderr, "Command cancelled", ("Interrupted by user.",)) raise SystemExit(130) diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 6485d916..2fa4612d 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -350,25 +350,47 @@ def event(self, level: str, message: str, *, console: bool = True, **fields: flo self.console.write("\n".join(console_rows) + "\n") self.console.flush() + def incomplete_progress(self, label: str, status: str = "Incomplete") -> None: + """Replace the active progress bar with an incomplete status.""" + if not self.settings.console_enabled or not self.console.isatty(): + return + + rendered = f"{label} [{status}]" + + with self._lock: + if self.settings.color_enabled: + rendered = f"\033[93m\033[1m{rendered}\033[0m" + + self.console.write(f"\r\033[2K{rendered}\n") + self.console.flush() + def progress(self, label: str, checked: int, total: int, current: str = "") -> None: """Render one in-place dependency-free progress bar on interactive consoles.""" if not self.settings.console_enabled or not self.console.isatty(): return + bounded_total = max(total, 1) bounded_checked = min(max(checked, 0), bounded_total) - available = max(self._console_width() - len(label) - len(str(bounded_total)) * 2 - 12, 16) + available = max( + self._console_width() - len(label) - len(str(bounded_total)) * 2 - 12, + 16, + ) filled = int(available * bounded_checked / bounded_total) bar = "=" * filled + (">" if filled < available else "") bar = f"{bar:<{available}}" suffix = f" {current}" if current else "" rendered = f"{label} [{bar}] {bounded_checked}/{bounded_total}{suffix}" rendered = rendered[: self._console_width()] + with self._lock: if self.settings.color_enabled: rendered = f"\033[96m\033[1m{rendered}\033[0m" + self.console.write(f"\r\033[2K{rendered}") + if bounded_checked >= bounded_total: self.console.write("\r\033[2K") + self.console.flush() def raw(self, message: str, *, end: str = "\n") -> None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 1d033ca1..ee18e0f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -13,8 +13,8 @@ from logicytics.cli import CLI, cli_methods, main from logicytics.contracts import Capability -from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.module.configuration import default_config +from logicytics.module.modes import EXECUTION_MODES, LEGACY_MODE_ALIASES, mode_matrix from logicytics.platform_adapters import process_adapter From 97dfe15716f1d077c1be584f101cdfd327351ba0 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Wed, 9 Sep 2026 18:08:10 +0300 Subject: [PATCH 281/316] feat(logging): add adaptive full-width preflight progress rendering Signed-off-by: DefinetlyNotAI --- logicytics/cli/__init__.py | 1 + logicytics/module/logging.py | 319 ++++++++++++++++++++++++++++++++--- 2 files changed, 297 insertions(+), 23 deletions(-) diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 6f28110e..040ead1d 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -5,6 +5,7 @@ import sys from pathlib import Path +from logicytics.cli.commands import cli_methods, CLI from logicytics.terminal import terminal_lifecycle from logicytics.virtual_environment import ( is_running_in_virtual_environment, diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 2fa4612d..ee400fe1 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -64,6 +64,7 @@ _LOGGER_LOCK = RLock() _APPLICATION_LOGGERS: dict[Path, ApplicationLogger] = {} _EVENT_LOGGERS: dict[tuple[Path, str, str | None], FileEventLogger] = {} +_ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") def _normalize_level(level: str) -> str: @@ -95,6 +96,115 @@ def __init__( self.path.parent.mkdir(parents=True, exist_ok=True) self._prepare_file() + self._last_console_line = "" + self._progress_indent = "" + self._progress_checked = 0 + self._progress_total = 0 + self._progress_current = "" + + @staticmethod + def _visible_text(text: str) -> str: + """Return text with ANSI escape sequences removed.""" + return _ANSI_ESCAPE.sub("", text) + + def _leading_indent(self, text: str) -> str: + """Return the leading whitespace from visible terminal text.""" + visible = self._visible_text(text) + + return visible[: len(visible) - len(visible.lstrip())] + + def _remember_console_line(self, text: str) -> None: + """Remember the last visible non-empty console line.""" + visible = self._visible_text(text).rstrip("\n") + + if "\n" in visible: + visible = visible.split("\n")[-1] + + if visible: + self._last_console_line = visible + + @staticmethod + def _shorten_component(component: str) -> str: + """ + Shorten one dotted-name component to initials. + + Examples: + packet_capture -> pc + process_memory_map -> pmm + network -> n + collector -> c + """ + parts = [part for part in component.split("_") if part] + + if not parts: + return component + + if len(parts) == 1: + return parts[0][0] + + return "".join(part[0] for part in parts) + + def _shorten_current(self, current: str, max_width: int) -> str: + """ + Render dotted collector names in compact code form. + + The final component is always abbreviated from underscore-separated + words, while namespaces remain readable until width requires further + shortening. + + Examples: + collector.network.packet_capture + -> collector.network.pc + -> collector.n.pc + -> c.n.pc + + collector.system.process_memory_map + -> collector.system.pmm + -> collector.s.pmm + -> c.s.pmm + """ + if not current: + return "" + + components = current.split(".") + + if len(components) <= 1: + return current + + shortened = components.copy() + + # Always use compact code form for the final component. + shortened[-1] = self._shorten_component(shortened[-1]) + + result = ".".join(shortened) + + if len(result) <= max_width: + return result + + # Shorten namespace components from right to left while preserving + # the leading namespace for as long as possible. + for index in range(len(shortened) - 2, 0, -1): + shortened[index] = self._shorten_component(shortened[index]) + result = ".".join(shortened) + + if len(result) <= max_width: + return result + + # Finally abbreviate the first component. + shortened[0] = self._shorten_component(shortened[0]) + result = ".".join(shortened) + + if len(result) <= max_width: + return result + + if max_width <= 0: + return "" + + if max_width <= 3: + return result[-max_width:] + + return f"…{result[-(max_width - 1):]}" + def _prepare_file(self) -> None: """Apply explicit deletion, retention, and bounded truncation policies.""" if self.settings.delete_previous and self.path.exists(): @@ -333,60 +443,211 @@ def event(self, level: str, message: str, *, console: bool = True, **fields: flo if self.settings.console_enabled and console: self._render_step(message) marker, marker_color, text_color = _LEVEL_PRESENTATION[normalized] + if not self._supports_unicode(): - marker = {"\u25cf": "*", "\u00d7": "X", "\u00b7": "."}.get(marker, marker) + marker = {"●": "*", "×": "X", "·": "."}.get(marker, marker) + console_rows = self._console_rows(marker, console_message) + if self.settings.color_enabled and self.console.isatty(): marker_prefix = f" {marker} " first_row = self._color_console_text( - console_rows[0][len(marker_prefix) :], + console_rows[0][len(marker_prefix):], text_color, ) - colored_rows = f"{marker_color}{_BOLD}{marker_prefix}{_RESET}{first_row}" + colored_rows = ( + f"{marker_color}{_BOLD}" + f"{marker_prefix}{_RESET}" + f"{first_row}" + ) + if len(console_rows) > 1: - colored_rows += "\n" + "\n".join(self._color_console_text(row, text_color) for row in console_rows[1:]) + colored_rows += "\n" + "\n".join( + self._color_console_text(row, text_color) + for row in console_rows[1:] + ) + self.console.write(f"{colored_rows}{_RESET}\n") else: self.console.write("\n".join(console_rows) + "\n") - self.console.flush() - def incomplete_progress(self, label: str, status: str = "Incomplete") -> None: - """Replace the active progress bar with an incomplete status.""" + self.console.flush() + self._remember_console_line(console_rows[-1]) + + def incomplete_progress( + self, + label: str, + checked: int | None = None, + total: int | None = None, + status: str = "Incomplete", + ) -> None: + """Replace the active progress bar with an incomplete x/y status.""" if not self.settings.console_enabled or not self.console.isatty(): return - rendered = f"{label} [{status}]" + bounded_total = max( + total if total is not None else self._progress_total, + 1, + ) + bounded_checked = min( + max( + checked if checked is not None else self._progress_checked, + 0, + ), + bounded_total, + ) + + rendered = ( + f"{self._progress_indent}" + f"{label} [{status}] " + f"{bounded_checked}/{bounded_total}" + ) with self._lock: if self.settings.color_enabled: - rendered = f"\033[93m\033[1m{rendered}\033[0m" + rendered = f"\033[91m\033[1m{rendered}\033[0m" self.console.write(f"\r\033[2K{rendered}\n") self.console.flush() - def progress(self, label: str, checked: int, total: int, current: str = "") -> None: - """Render one in-place dependency-free progress bar on interactive consoles.""" + self._remember_console_line(rendered) + + self._progress_indent = "" + self._progress_checked = 0 + self._progress_total = 0 + self._progress_current = "" + + def progress( + self, + label: str, + checked: int, + total: int, + current: str = "", + ) -> None: + """Render one full-width adaptive in-place progress bar.""" if not self.settings.console_enabled or not self.console.isatty(): return bounded_total = max(total, 1) bounded_checked = min(max(checked, 0), bounded_total) - available = max( - self._console_width() - len(label) - len(str(bounded_total)) * 2 - 12, - 16, + + if checked == 0 or not self._progress_indent: + self._progress_indent = self._leading_indent( + self._last_console_line + ) + + self._progress_checked = bounded_checked + self._progress_total = bounded_total + self._progress_current = current + + console_width = self._console_width() + count = f"{bounded_checked}/{bounded_total}" + + if current: + display_current = self._shorten_current( + current, + max_width=console_width, + ) + else: + display_current = "" + + suffix = f" {display_current}" if display_current else "" + + # Layout: + # + #
- - - - - - - - -
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
- + + + BIOS information + +

BIOS information

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
+ diff --git a/tests/test_cli.py b/tests/test_cli.py index 0dc15385..bf22a8bc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -292,7 +292,7 @@ def test_argument_error_keeps_the_complete_command_reference(self) -> None: self.assertIn("collector_id", rendered) def test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window( - self, + self, ) -> None: """The paired update options launch exactly one shell-free visible Windows action.""" with tempfile.TemporaryDirectory() as temporary: @@ -309,6 +309,7 @@ def reachable_git(command: list[str], **_: object) -> subprocess.CompletedProces if command == ["git", "ls-remote", "--exit-code", "origin", "HEAD"]: return subprocess.CompletedProcess(command, 0, "head\tHEAD\n", "") self.fail(f"unexpected Git command: {command}") + output = io.StringIO() with ( @@ -432,7 +433,7 @@ def test_update_reports_a_missing_git_executable(self) -> None: self.assertIn("Install Git, then run the update command again.", output.getvalue()) def test_new_window_launcher_uses_current_interpreter_without_a_shell( - self, + self, ) -> None: """Visible maintenance windows preserve argument boundaries and repository cwd.""" root = Path("C:/repo").resolve() diff --git a/tests/test_collector_cancellation.py b/tests/test_collector_cancellation.py index d2e8cec4..3ad10cc3 100644 --- a/tests/test_collector_cancellation.py +++ b/tests/test_collector_cancellation.py @@ -33,12 +33,12 @@ class _RejectingArtifactWriter(ArtifactWriter): """Reject artifact publication during cancellation tests.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> Artifact: raise AssertionError(f"cancelled collector unexpectedly registered {source}") @@ -47,19 +47,19 @@ class _NullEventLogger(EventLogger): """Discard structured progress events emitted during tests.""" def event( - self, - level: str, - message: str, - **fields: float | str, + self, + level: str, + message: str, + **fields: float | str, ) -> None: return None def _context( - workspace: Path, - settings: dict[str, object] | None = None, - *, - collector_id: str = "core.test.cancellation", + workspace: Path, + settings: dict[str, object] | None = None, + *, + collector_id: str = "core.test.cancellation", ) -> tuple[CollectorContext, Path]: """Create a real CollectorContext and its cancellation sentinel.""" @@ -112,8 +112,8 @@ def test_sensitive_copy_cancellation_removes_unpublished_bytes(self) -> None: ) def cancel_after_copy( - source: Path, - destination: Path, + source: Path, + destination: Path, ) -> Path: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) @@ -121,8 +121,8 @@ def cancel_after_copy( return destination with patch( - "core.filesystem.sensitive_file_inventory._copy", - side_effect=cancel_after_copy, + "core.filesystem.sensitive_file_inventory._copy", + side_effect=cancel_after_copy, ): result = sensitive_file_inventory.SensitiveFileInventoryCollector().collect(context) @@ -137,7 +137,7 @@ def cancel_after_copy( ) def test_browser_and_media_copy_cancellation_remove_unpublished_bytes( - self, + self, ) -> None: """Browser and media collectors remove incomplete private copies.""" @@ -158,9 +158,9 @@ def test_browser_and_media_copy_cancellation_remove_unpublished_bytes( ) def cancel_browser_copy( - _source: Path, - destination: Path, - copied_bytes: int, + _source: Path, + destination: Path, + copied_bytes: int, ) -> tuple[Path, int]: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(b"history") @@ -191,7 +191,8 @@ def cancel_browser_copy( self.assertEqual( [], - [path for path in browser_context.workspace.rglob("*") if path.is_file() and path != browser_cancellation], + [path for path in browser_context.workspace.rglob("*") if + path.is_file() and path != browser_cancellation], ) home = root / "home" @@ -206,8 +207,8 @@ def cancel_browser_copy( ) def cancel_media_copy( - source: Path, - destination: Path, + source: Path, + destination: Path, ) -> None: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) @@ -264,7 +265,7 @@ def test_event_queries_cannot_publish_after_cancellation(self) -> None: for collector_type, subprocess_target, collector_id in collectors: with self.subTest( - collector=collector_type.__name__, + collector=collector_type.__name__, ): context, cancellation_file = _context( root / collector_type.__name__, @@ -272,8 +273,8 @@ def test_event_queries_cannot_publish_after_cancellation(self) -> None: ) def finish_after_cancellation( - *_args: Any, - **_kwargs: Any, + *_args: Any, + **_kwargs: Any, ) -> subprocess.CompletedProcess[str]: cancellation_file.touch() @@ -285,8 +286,8 @@ def finish_after_cancellation( ) with patch( - subprocess_target, - side_effect=finish_after_cancellation, + subprocess_target, + side_effect=finish_after_cancellation, ): result = collector_type().collect(context) @@ -301,7 +302,7 @@ def finish_after_cancellation( ) def test_system_data_and_ssh_copy_cancellation_remove_partial_backups( - self, + self, ) -> None: """System and SSH collectors delete private incomplete copies.""" @@ -321,8 +322,8 @@ def test_system_data_and_ssh_copy_cancellation_remove_partial_backups( ) def cancel_system_copy( - source: Path, - destination: Path, + source: Path, + destination: Path, ) -> None: destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(source.read_bytes()) @@ -351,7 +352,8 @@ def cancel_system_copy( self.assertEqual( [], - [path for path in system_context.workspace.rglob("*") if path.is_file() and path != system_cancellation], + [path for path in system_context.workspace.rglob("*") if + path.is_file() and path != system_cancellation], ) home = root / "home" @@ -368,11 +370,11 @@ def cancel_system_copy( original_write = zipfile.ZipFile.write def cancel_archive_write( - archive: zipfile.ZipFile, - filename: str | os.PathLike[str], - arcname: str | os.PathLike[str] | None = None, - compress_type: int | None = None, - compresslevel: int | None = None, + archive: zipfile.ZipFile, + filename: str | os.PathLike[str], + arcname: str | os.PathLike[str] | None = None, + compress_type: int | None = None, + compresslevel: int | None = None, ) -> None: bound_write = original_write.__get__(archive, type(archive)) @@ -407,7 +409,7 @@ def cancel_archive_write( self.assertFalse((ssh_context.workspace / "ssh_backup.zip").exists()) def test_packet_and_memory_loops_stop_before_serialization( - self, + self, ) -> None: """Packet and memory loops stop before serializing evidence.""" @@ -434,19 +436,19 @@ def bind(_address: object) -> None: @staticmethod def setsockopt( - *_args: object, + *_args: object, ) -> None: return None @staticmethod def ioctl( - *_args: object, + *_args: object, ) -> None: return None @staticmethod def recvfrom( - _buffer_size: int, + _buffer_size: int, ) -> tuple[bytes, tuple[str, int]]: return b"", ("127.0.0.1", 0) @@ -455,8 +457,8 @@ def close() -> None: return None def cancel_wait( - *_args: object, - **_kwargs: object, + *_args: object, + **_kwargs: object, ) -> tuple[ list[object], list[object], @@ -529,7 +531,8 @@ def cancel_on_current_process() -> int: self.assertEqual( [], - [path for path in memory_context.workspace.rglob("*") if path.is_file() and path != memory_cancellation], + [path for path in memory_context.workspace.rglob("*") if + path.is_file() and path != memory_cancellation], ) diff --git a/tests/test_configuration.py b/tests/test_configuration.py index d69af64e..c89be8c7 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -221,7 +221,7 @@ def test_runtime_temporary_directory_is_project_local_by_default_or_system_selec load_config(root) def test_configuration_manifest_redacts_nested_secrets_without_mutating_worker_settings( - self, + self, ) -> None: """Manifest snapshots hide credentials while collectors retain configured access.""" with tempfile.TemporaryDirectory() as temporary: @@ -354,7 +354,7 @@ def test_configuration_validates_filesystem_and_sensitive_inventory_bounds(self) self.assertEqual(settings, configuration.settings_for(collector_id)) def test_configuration_validates_metadata_only_memory_map_limits_and_workspace_paths( - self, + self, ) -> None: """The existing metadata-only memory mapper rejects unsafe values before worker launch.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_deep_collector_resilience.py b/tests/test_deep_collector_resilience.py index 45b3df43..2898f84b 100644 --- a/tests/test_deep_collector_resilience.py +++ b/tests/test_deep_collector_resilience.py @@ -41,9 +41,9 @@ def test_detailed_tasklist_timeout_is_skipped(self) -> None: with tempfile.TemporaryDirectory() as temporary: context = _context(Path(temporary), "core.process.detailed_processes") with patch.object( - detailed_processes.subprocess, - detailed_processes.subprocess.run.__name__, - side_effect=subprocess.TimeoutExpired(["tasklist"], 40), + detailed_processes.subprocess, + detailed_processes.subprocess.run.__name__, + side_effect=subprocess.TimeoutExpired(["tasklist"], 40), ): result = detailed_processes.DetailedProcessesCollector().collect(context) self.assertIs(CollectorStatus.SKIPPED, result.status) @@ -54,9 +54,9 @@ def test_sysinternals_timeouts_are_recorded_in_the_report(self) -> None: with tempfile.TemporaryDirectory() as temporary: context = _context(Path(temporary), "core.diagnostics.sysinternals_report") with patch.object( - sysinternals_report.subprocess, - sysinternals_report.subprocess.run.__name__, - side_effect=subprocess.TimeoutExpired(["psfile.exe"], 25), + sysinternals_report.subprocess, + sysinternals_report.subprocess.run.__name__, + side_effect=subprocess.TimeoutExpired(["psfile.exe"], 25), ): result = sysinternals_report.SysinternalsReportCollector().collect(context) report = (context.workspace / "sysinternals_report.txt").read_text(encoding="utf-8") @@ -69,9 +69,9 @@ def test_network_interfaces_accepts_unmatched_adapter_records(self) -> None: context = _context(Path(temporary), "core.network.network_interfaces") response = '[{"InterfaceAlias":"stale","IPAddress":"192.0.2.1","PrefixLength":24}]' with patch.object( - network_interfaces.subprocess, - network_interfaces.subprocess.run.__name__, - return_value=subprocess.CompletedProcess((), 0, response, ""), + network_interfaces.subprocess, + network_interfaces.subprocess.run.__name__, + return_value=subprocess.CompletedProcess((), 0, response, ""), ) as run: result = network_interfaces.NetworkInterfacesCollector().collect(context) self.assertIs(CollectorStatus.SUCCEEDED, result.status) diff --git a/tests/test_golden_outputs.py b/tests/test_golden_outputs.py index acd34c55..832a0a13 100644 --- a/tests/test_golden_outputs.py +++ b/tests/test_golden_outputs.py @@ -36,12 +36,12 @@ def __init__(self) -> None: self.contents = b"" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - transformations: tuple[str, ...] = (), - **kwargs: object, + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + **kwargs: object, ) -> Artifact: self.contents = source.read_bytes() return Artifact( @@ -78,9 +78,9 @@ def test_text_output_matches_golden_file(self) -> None: with tempfile.TemporaryDirectory() as temporary: writer = _CaptureWriter() with patch.object( - system_details.subprocess, - system_details.subprocess.run.__name__, - return_value=CompletedProcess([], 0, expected, ""), + system_details.subprocess, + system_details.subprocess.run.__name__, + return_value=CompletedProcess([], 0, expected, ""), ): system_details.SystemDetailsCollector().collect( _context( @@ -96,9 +96,9 @@ def test_csv_output_matches_golden_file(self) -> None: with tempfile.TemporaryDirectory() as temporary: writer = _CaptureWriter() with patch.object( - running_processes.subprocess, - running_processes.subprocess.run.__name__, - return_value=CompletedProcess([], 0, expected, ""), + running_processes.subprocess, + running_processes.subprocess.run.__name__, + return_value=CompletedProcess([], 0, expected, ""), ): running_processes.RunningProcessesCollector().collect( _context( diff --git a/tests/test_interaction.py b/tests/test_interaction.py index 9d679f2a..efc70b58 100644 --- a/tests/test_interaction.py +++ b/tests/test_interaction.py @@ -114,7 +114,7 @@ def test_semantic_matching_can_fall_back_to_prior_inputs(self) -> None: self.assertEqual("collect the strange moon report", statistics["common_input"]) def test_usage_graph_contains_only_current_nonzero_command_and_mode_counts( - self, + self, ) -> None: """The graph must match recorded usage rather than an obsolete fixed flag list.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_logging.py b/tests/test_logging.py index 1ee6a496..57cbefa5 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -193,13 +193,13 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self self.assertEqual((root / "output" / "data" / "zip").resolve(), layout.packages) self.assertEqual((root / "output" / "data" / "hashes").resolve(), layout.hashes) for directory in ( - layout.data, - layout.runs, - layout.logs, - layout.debug_logs, - layout.performance_logs, - layout.packages, - layout.hashes, + layout.data, + layout.runs, + layout.logs, + layout.debug_logs, + layout.performance_logs, + layout.packages, + layout.hashes, ): self.assertTrue(directory.is_dir()) config_path = root / "logicytics.yaml" @@ -226,11 +226,11 @@ def test_output_layout_and_logging_configuration_are_complete_and_validated(self self.assertEqual(7, configuration.logging.retention_days) for invalid_logging in ( - {"level": "TRACE"}, - {"maximum_bytes": True}, - {"retention_days": -1}, - {"console_enabled": 1}, - {"unknown": True}, + {"level": "TRACE"}, + {"maximum_bytes": True}, + {"retention_days": -1}, + {"console_enabled": 1}, + {"unknown": True}, ): config_path.write_text( json.dumps({"schema_version": 4, "logging": invalid_logging}), diff --git a/tests/test_maintenance.py b/tests/test_maintenance.py index 15ed9864..1c15b9f1 100644 --- a/tests/test_maintenance.py +++ b/tests/test_maintenance.py @@ -35,7 +35,7 @@ class MaintenanceTests(unittest.TestCase): """Maintenance, integrity manifests, developer actions, and Sysinternals behavior.""" def test_maintenance_configuration_requires_pinned_https_and_python_order( - self, + self, ) -> None: """Integrity endpoints and Python policy fail closed during configuration loading.""" with tempfile.TemporaryDirectory() as temporary: @@ -57,10 +57,10 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( self.assertEqual("3.12", loaded.maintenance.recommended_python) for mutation in ( - {"remote_manifest_url": "http://example.invalid/project.json"}, - {"remote_manifest_sha256": None}, - {"local_manifest_path": "../escape.json"}, - {"minimum_python": "3.12", "recommended_python": "3.11"}, + {"remote_manifest_url": "http://example.invalid/project.json"}, + {"remote_manifest_sha256": None}, + {"local_manifest_path": "../escape.json"}, + {"minimum_python": "3.12", "recommended_python": "3.11"}, ): invalid = json.loads(json.dumps(valid)) invalid["maintenance"].update(mutation) @@ -69,7 +69,7 @@ def test_maintenance_configuration_requires_pinned_https_and_python_order( load_config(root, config) def test_authenticated_remote_manifest_is_strict_data_only_configuration( - self, + self, ) -> None: """Pinned HTTPS bytes are accepted, while unpinned or execution-bearing data is rejected.""" payload = json.dumps( @@ -86,9 +86,9 @@ def test_authenticated_remote_manifest_is_strict_data_only_configuration( response = MagicMock() response.__enter__.return_value.read.return_value = payload with patch.object( - maintenance.urllib.request, - "urlopen", - return_value=response, + maintenance.urllib.request, + "urlopen", + return_value=response, ): manifest = fetch_remote_manifest(settings) diff --git a/tests/test_mods.py b/tests/test_mods.py index 9a3d32dd..6a9af0c4 100644 --- a/tests/test_mods.py +++ b/tests/test_mods.py @@ -120,7 +120,7 @@ def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> self.assertIsNone(archive.testzip()) def test_python_mod_cannot_mutate_project_configuration_without_write_approval( - self, + self, ) -> None: """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" with tempfile.TemporaryDirectory() as temporary: @@ -137,7 +137,8 @@ def test_python_mod_cannot_mutate_project_configuration_without_write_approval( scripts = { "good.py": ("from pathlib import Path\nPath('report.txt').write_text('ok', encoding='utf-8')\n"), - "malicious.py": (f"from pathlib import Path\nPath({str(configuration_path)!r}).write_text('replaced', encoding='utf-8')\n"), + "malicious.py": ( + f"from pathlib import Path\nPath({str(configuration_path)!r}).write_text('replaced', encoding='utf-8')\n"), } for filename, source in scripts.items(): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 0349d863..4281ad08 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -236,7 +236,7 @@ def test_package_separates_typed_evidence_reports_logs_hashes_and_metadata(self) outcome.manifest.to_dict() def test_configured_output_root_colocates_packages_without_touching_legacy_evidence( - self, + self, ) -> None: """Custom roots own runs and ZIPs; old ACCESS evidence is neither moved nor deleted.""" with tempfile.TemporaryDirectory() as temporary: @@ -331,7 +331,8 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( report = preflight(root) self.assertEqual((), report.invalid) configuration = default_config(root) - original = RunSupervisor(root, configuration).run(build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True))) + original = RunSupervisor(root, configuration).run( + build_plan(report, RunRequest(max_workers=1, acknowledge_authorization=True))) original_package_metadata = original.manifest.package assert original_package_metadata is not None original_manifest = original.manifest_path.read_bytes() @@ -378,7 +379,7 @@ def test_selected_collector_rerun_preserves_original_run_and_separates_evidence( self.assertIn(f"Parent run: {original.manifest.run_id}", summary) def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_redacted( - self, + self, ) -> None: """Evidence retains intentional secrets, but no packaged diagnostics disclose them.""" with tempfile.TemporaryDirectory() as temporary: @@ -444,7 +445,8 @@ def test_sensitive_artifact_bytes_are_preserved_while_packaged_diagnostics_are_r archive.read(f"evidence/{artifact.evidence_kind.value}/{artifact.relative_path}").decode("utf-8"), ) diagnostics = "\n".join( - archive.read(name).decode("utf-8") for name in archive.namelist() if not name.startswith("evidence/") + archive.read(name).decode("utf-8") for name in archive.namelist() if + not name.startswith("evidence/") ) self.assertNotIn("evidence-password", diagnostics) self.assertNotIn("evidence-token", diagnostics) @@ -487,7 +489,8 @@ def test_worker_exception_secrets_are_redacted_from_manifest_and_package(self) - packaged_record = json.loads(archive.read("metadata/manifest.json"))["collectors"][0] self.assertEqual(failure, packaged_record["failure"]) diagnostics = "\n".join( - archive.read(name).decode("utf-8") for name in archive.namelist() if not name.startswith("evidence/") + archive.read(name).decode("utf-8") for name in archive.namelist() if + not name.startswith("evidence/") ) self.assertNotIn("crash-password", diagnostics) self.assertNotIn("crash-token", diagnostics) @@ -527,7 +530,7 @@ def test_performance_report_is_finalized_before_automatic_packaging(self) -> Non package_run(outcome) def test_collector_resource_progress_persists_in_manifest_summary_and_performance_report( - self, + self, ) -> None: """Incremental progress fields remain visible live and in every packaged diagnostic.""" with tempfile.TemporaryDirectory() as temporary: @@ -578,7 +581,8 @@ def capture_manifest(path: Path, manifest: Any) -> None: self.assertGreater(float(record.progress["elapsed_seconds"]), 0) self.assertTrue( any( - snapshot["collectors"][0]["status"] == "running" and snapshot["collectors"][0]["progress"]["files_scanned"] >= 3 + snapshot["collectors"][0]["status"] == "running" and snapshot["collectors"][0]["progress"][ + "files_scanned"] >= 3 for snapshot in snapshots ) ) @@ -618,9 +622,9 @@ def test_package_excludes_unregistered_files_and_rejects_tampered_artifacts(self original_write = packaging._stream_archive_member def tampering_write( - archive: zipfile.ZipFile, - filename: Path, - arcname: str, + archive: zipfile.ZipFile, + filename: Path, + arcname: str, ) -> None: if arcname.startswith("evidence/"): archive.writestr(arcname, b"tampered artifact bytes") @@ -690,7 +694,7 @@ def fail_sidecar(source: str | Path, destination: str | Path) -> None: self.assertFalse(hash_path.with_suffix(".sha256.backup").exists()) def test_package_rejects_manifest_artifact_path_escape_and_forged_collector_ownership( - self, + self, ) -> None: """A modified manifest cannot package outside files or another collector's evidence.""" with tempfile.TemporaryDirectory() as temporary: @@ -883,7 +887,8 @@ def read(self, size: int = -1) -> bytes: def guarded_open(path: Path, *arguments: Any, **options: Any) -> Any: stream = original_open(path, *arguments, **options) resolved_path = path.resolve() - if resolved_path in {source.resolve(), expected_package_path.resolve()} and arguments and arguments[0] == "rb": + if resolved_path in {source.resolve(), expected_package_path.resolve()} and arguments and arguments[ + 0] == "rb": return BoundedReader(stream, path) return stream diff --git a/tests/test_planning.py b/tests/test_planning.py index 01a2f698..6492841f 100644 --- a/tests/test_planning.py +++ b/tests/test_planning.py @@ -48,7 +48,7 @@ def _collector_ids(self, candidates) -> list[str]: return collector_ids def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( - self, + self, ) -> None: """Power actions remain explicit and cannot run before durable package publication.""" arguments = cli_methods.parser().parse_args( @@ -113,9 +113,9 @@ def test_post_run_actions_are_typed_exclusive_and_require_verified_packaging( ) with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - return_value=completed, + ProcessAdapter, + ProcessAdapter.run.__name__, + return_value=completed, ) as command: RunSupervisor._execute_post_run_action( PostRunAction.REBOOT, @@ -265,9 +265,9 @@ def test_policy_error_reports_every_selected_collector(self) -> None: (root / "plugins").mkdir() for filename, capability in ( - ("a_subprocess", "Capability.SUBPROCESS"), - ("b_network", "Capability.NETWORK"), - ("c_subprocess", "Capability.SUBPROCESS"), + ("a_subprocess", "Capability.SUBPROCESS"), + ("b_network", "Capability.NETWORK"), + ("c_subprocess", "Capability.SUBPROCESS"), ): source = ( delayed_collector_source(filename, 0.0) @@ -278,14 +278,15 @@ def test_policy_error_reports_every_selected_collector(self) -> None: .replace( " capabilities=(),", f" capabilities=({capability},),\n" - + (" network_access=NetworkAccess.LOCAL,\n" if capability == "Capability.NETWORK" else ""), + + ( + " network_access=NetworkAccess.LOCAL,\n" if capability == "Capability.NETWORK" else ""), ) ) (core_directory / f"{filename}.py").write_text(source, encoding="utf-8") with self.assertRaisesRegex( - PlanError, - "selected collector policy validation failed", + PlanError, + "selected collector policy validation failed", ) as rejected: build_plan( preflight(root), @@ -304,9 +305,9 @@ def test_policy_error_reports_every_selected_collector(self) -> None: message.index("core.system.c_subprocess"), ) for collector_id in ( - "core.system.a_subprocess", - "core.system.b_network", - "core.system.c_subprocess", + "core.system.a_subprocess", + "core.system.b_network", + "core.system.c_subprocess", ): self.assertIn(collector_id, message) self.assertIn( @@ -395,7 +396,7 @@ def test_configured_worker_limit_blocks_oversized_run_before_workspace_creation( self.assertFalse(configuration.runtime.output_root.exists()) def test_collector_command_runs_only_the_selected_id_and_declared_dependencies( - self, + self, ) -> None: """Direct execution never pulls unrelated profile members into its supervised run.""" with tempfile.TemporaryDirectory() as temporary: @@ -679,8 +680,8 @@ def test_elevated_collectors_require_approval_and_administrator_privileges(self) with self.assertRaisesRegex(PlanError, "administrator account"): build_plan(report, approved) with patch( - "logicytics.module.planner.inspect_environment", - return_value=EnvironmentReport(True, True, None), + "logicytics.module.planner.inspect_environment", + return_value=EnvironmentReport(True, True, None), ): plan = build_plan(report, approved) self.assertEqual(1, len(plan.collectors)) diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 769709e9..c8d5bc2d 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -31,19 +31,19 @@ class ProcessAdapterTests(unittest.TestCase): def test_process_adapter_normalizes_and_delegates_shell_free_commands(self) -> None: def execute( - command: Sequence[str], - *, - stdout: BinaryIO, - stderr: BinaryIO, - **_options: object, + command: Sequence[str], + *, + stdout: BinaryIO, + stderr: BinaryIO, + **_options: object, ) -> subprocess.CompletedProcess[str]: stdout.write(b"output") stderr.write(b"warning") return subprocess.CompletedProcess(command, 7) with patch( - "logicytics.platform_adapters.subprocess.run", - side_effect=execute, + "logicytics.platform_adapters.subprocess.run", + side_effect=execute, ) as invoke: result = ProcessAdapter().run( ["tool", Path("argument")], @@ -111,9 +111,9 @@ def test_process_adapter_starts_shell_free_processes_and_reads_host_memory(self) ) self.assertEqual(("tool", "argument"), popen.call_args.args[0]) with patch.object( - windows_api_adapter, - windows_api_adapter.process_working_set.__name__, - return_value=4096, + windows_api_adapter, + windows_api_adapter.process_working_set.__name__, + return_value=4096, ) as memory: self.assertEqual( 4096, @@ -172,9 +172,9 @@ def test_packet_capture_streams_rows_to_its_artifact_file(self) -> None: def test_process_adapter_rejects_shell_empty_and_unbounded_timeout_inputs(self) -> None: adapter = ProcessAdapter() for command, options, message in ( - ([], {}, "at least one"), - (["tool"], {"shell": True}, "shell"), - (["tool"], {"timeout": 0}, "timeout"), + ([], {}, "at least one"), + (["tool"], {"shell": True}, "shell"), + (["tool"], {"timeout": 0}, "timeout"), ): with self.subTest(command=command, options=options): with self.assertRaisesRegex(ValueError, message): diff --git a/tests/test_preflight.py b/tests/test_preflight.py index c6a9949d..d7ce0245 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -112,7 +112,7 @@ def test_core_and_plugin_files_must_use_lowercase_snake_case_python_names(self) ) def test_preflight_cache_requires_source_interpreter_contract_and_configuration_identity( - self, + self, ) -> None: """Only an exact validation context may reuse an isolated runtime probe.""" with tempfile.TemporaryDirectory() as temporary: @@ -125,27 +125,27 @@ def test_preflight_cache_requires_source_interpreter_contract_and_configuration_ self.assertEqual(1, len(initial.valid), initial.invalid) with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, ) as probe: cached = preflight(root, configuration_hash="configuration-a") self.assertEqual(1, len(cached.valid), cached.invalid) probe.assert_not_called() with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, ) as probe: invalidated = preflight(root, configuration_hash="configuration-b") self.assertEqual(1, len(invalidated.valid), invalidated.invalid) self.assertEqual(1, probe.call_count) with patch.object( - ProcessAdapter, - ProcessAdapter.run.__name__, - wraps=ProcessAdapter.run, + ProcessAdapter, + ProcessAdapter.run.__name__, + wraps=ProcessAdapter.run, ) as probe: explicitly_invalidated = preflight( root, @@ -164,7 +164,8 @@ def test_preflight_reports_collector_progress_before_and_after_validation(self) collector_path.write_text(COLLECTOR, encoding="utf-8") progress: list[tuple[str, int, int, str]] = [] - report = preflight(root, progress=lambda phase, checked, total, current: progress.append((phase, checked, total, current))) + report = preflight(root, progress=lambda phase, checked, total, current: progress.append( + (phase, checked, total, current))) self.assertEqual(1, len(report.valid), report.invalid) self.assertEqual(("checking", 0, 1, "core.system_info"), progress[0]) @@ -277,7 +278,8 @@ def test_run_output_budget_bounds_concurrent_collector_evidence(self) -> None: records["core.system.a_first"].errors, ) self.assertEqual("failed", records["core.system.b_second"].status) - self.assertTrue(any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors)) + self.assertTrue( + any("maximum_run_output_bytes" in error for error in records["core.system.b_second"].errors)) self.assertLessEqual(outcome.manifest.total_artifact_bytes, 7) def test_exhausted_run_output_budget_skips_unstarted_collectors(self) -> None: @@ -465,9 +467,9 @@ def test_preflight_cli_reports_quarantine_and_blocks_selected_invalid_plugins(se ) for arguments, expected_exit in ( - (["preflight"], 0), - (["preflight", "--include", "plugin.broken_plugin"], 2), - (["preflight", "--plugins"], 2), + (["preflight"], 0), + (["preflight", "--include", "plugin.broken_plugin"], 2), + (["preflight", "--plugins"], 2), ): with self.subTest(arguments=arguments): output = io.StringIO() @@ -506,7 +508,8 @@ def test_preflight_rejects_wrong_lifecycle_return_type(self) -> None: report = preflight(root) self.assertEqual(1, len(report.invalid)) self.assertIn("collect must return CollectorResult", report.invalid[0].static_errors) - diagnostic = next(item for item in report.invalid[0].diagnostics if item.message == "collect must return CollectorResult") + diagnostic = next( + item for item in report.invalid[0].diagnostics if item.message == "collect must return CollectorResult") source_lines = collector_path.read_text(encoding="utf-8").splitlines() expected_line = next( index @@ -600,7 +603,7 @@ def test_preflight_rejects_malformed_validate_result(self) -> None: self.assertIn("validate() must return ValidationResult", report.invalid[0].runtime_error or "") def test_preflight_rejects_validation_filesystem_process_network_and_environment_side_effects( - self, + self, ) -> None: """No validation probe may mutate files, launch processes, open sockets, or alter its environment.""" with tempfile.TemporaryDirectory() as temporary: @@ -677,12 +680,12 @@ def test_preflight_rejects_a_hanging_validation_worker(self) -> None: (root / "plugins").mkdir() collector_path.write_text(COLLECTOR, encoding="utf-8") with patch.object( - discovery.process_adapter, - discovery.process_adapter.run.__name__, - side_effect=subprocess.TimeoutExpired( - ["validation-worker"], - timeout=10, - ), + discovery.process_adapter, + discovery.process_adapter.run.__name__, + side_effect=subprocess.TimeoutExpired( + ["validation-worker"], + timeout=10, + ), ): report = preflight(root) self.assertEqual(1, len(report.invalid)) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 2da8c513..9357ac18 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -215,7 +215,7 @@ def test_public_api_runs_queries_and_reads_verified_registered_evidence(self) -> collector.status = "failed" def test_public_run_snapshot_and_cli_expose_verified_collector_failure_and_duration( - self, + self, ) -> None: """Callers can inspect redacted lifecycle timing and actionable failure details without parsing manifests.""" with tempfile.TemporaryDirectory() as temporary: @@ -388,7 +388,7 @@ def test_public_run_queries_reject_traversal_forged_identity_and_manifest_links( query_run(root, outcome.manifest.run_id) def test_public_artifact_reads_reject_forgery_tampering_escapes_and_unbounded_access( - self, + self, ) -> None: """Only exact, bounded, manifest-cataloged bytes from the producing collector may be read.""" with tempfile.TemporaryDirectory() as temporary: diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py index 2fd55d2c..1cfb776b 100644 --- a/tests/test_release_documentation.py +++ b/tests/test_release_documentation.py @@ -24,10 +24,10 @@ def test_release_identity_is_final_and_no_provisional_claim_remains(self) -> Non self.assertIn("complete recreation", self.release.casefold()) self.assertIn("verified run", self.readme.casefold()) for provisional in ( - "being rebuilt", - "early v4 implementation", - "broader collection catalog is being rebuilt", - "to be released", + "being rebuilt", + "early v4 implementation", + "broader collection catalog is being rebuilt", + "to be released", ): with self.subTest(provisional=provisional): self.assertNotIn(provisional, self.readme.casefold()) @@ -48,7 +48,8 @@ def test_release_covers_every_specialty_mode_and_output_media_type(self) -> None with self.subTest(mode=mode): self.assertIn(f"`{mode}`", self.release) media_types = { - media_type for candidate in core if candidate.metadata is not None for media_type in candidate.metadata.output_media_types + media_type for candidate in core if candidate.metadata is not None for media_type in + candidate.metadata.output_media_types } for media_type in media_types: with self.subTest(media_type=media_type): @@ -75,26 +76,26 @@ def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> Non location = ( self.root / "docs" / document if document.endswith(".md") - and document - not in { - "CONTRIBUTING.md", - "SECURITY.md", - "CODE_OF_CONDUCT.md", - } + and document + not in { + "CONTRIBUTING.md", + "SECURITY.md", + "CODE_OF_CONDUCT.md", + } else self.root / document ) self.assertTrue(location.is_file()) self.assertIn("Logicytics Wiki", self.readme) contributor = (self.root / "CONTRIBUTING.md").read_text(encoding="utf-8") for requirement in ( - "Development setup", - "Architecture boundaries", - "Core collector changes", - "Plugins and MODs", - "Configuration changes", - "Testing expectations", - "conventional commit", - "Developer Certificate of Origin", + "Development setup", + "Architecture boundaries", + "Core collector changes", + "Plugins and MODs", + "Configuration changes", + "Testing expectations", + "conventional commit", + "Developer Certificate of Origin", ): with self.subTest(requirement=requirement): self.assertIn(requirement, contributor) diff --git a/tests/test_running_processes_collector.py b/tests/test_running_processes_collector.py index c1fdafd8..7d6bc926 100644 --- a/tests/test_running_processes_collector.py +++ b/tests/test_running_processes_collector.py @@ -16,12 +16,12 @@ class _UnusedArtifactWriter(ArtifactWriter): """Raise if a skipped collector unexpectedly attempts artifact registration.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - transformations: tuple[str, ...] = (), - evidence_kind: str | None = None, + self, + source: Path, + *, + media_type: str = "application/octet-stream", + transformations: tuple[str, ...] = (), + evidence_kind: str | None = None, ): raise AssertionError("a skipped collector must not register artifacts") @@ -61,9 +61,9 @@ def test_access_denied_is_skipped_not_failed(self) -> None: cancellation_file=workspace / ".cancelled", ) with patch.object( - module.subprocess, - module.subprocess.run.__name__, - return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), + module.subprocess, + module.subprocess.run.__name__, + return_value=CompletedProcess([], 1, "", "ERROR: Access denied"), ): result = collector_type().collect(context) self.assertEqual(CollectorStatus.SKIPPED, result.status) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index e5050c5b..7648d5e5 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -380,7 +380,7 @@ def test_failed_collector_with_registered_evidence_is_never_retried(self) -> Non self.assertIn("Remediation:", summary) def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolation_results( - self, + self, ) -> None: """A failed process finalizes once, preserves evidence, and cannot stop its neighbor.""" with tempfile.TemporaryDirectory() as temporary: @@ -431,7 +431,8 @@ def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolat assert package is not None with zipfile.ZipFile(Path(package["path"])) as archive: - packaged = {item["id"]: item for item in json.loads(archive.read("metadata/manifest.json"))["collectors"]} + packaged = {item["id"]: item for item in + json.loads(archive.read("metadata/manifest.json"))["collectors"]} summary = archive.read("reports/summary.txt").decode("utf-8") self.assertIn( "evidence/derived/core_system_a_failed/system.txt", @@ -446,7 +447,7 @@ def test_crashed_collector_runs_cleanup_and_packages_partial_evidence_and_isolat self.assertIn("collection failed after evidence registration", summary) def test_cleanup_failure_preserves_original_collector_crash_and_independent_results( - self, + self, ) -> None: """Cleanup errors must be reported without replacing the original failure.""" with tempfile.TemporaryDirectory() as temporary: @@ -661,7 +662,8 @@ def test_collector_timeout_terminates_spawned_subprocesses_without_stopping_peer ) outcome = RunSupervisor(root, default_config(root)).run(plan) records = {record.id: record for record in outcome.manifest.collectors} - child_pid = int((outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text(encoding="ascii")) + child_pid = int( + (outcome.run_directory / "collectors" / "core_system_a_tree" / "child.pid").read_text(encoding="ascii")) handle = open_process(child_pid) if handle is not None: @@ -793,9 +795,9 @@ def test_cancelled_run_writes_a_recoverable_package_and_manifest(self) -> None: ) with patch.object( - supervisor, - supervisor._supervise.__name__, - side_effect=KeyboardInterrupt, + supervisor, + supervisor._supervise.__name__, + side_effect=KeyboardInterrupt, ): outcome = supervisor.run(plan) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 2cb65e7a..23871c45 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -140,9 +140,9 @@ def test_parallel_unsafe_collector_runs_without_worker_overlap(self) -> None: def test_conflicting_resource_classes_run_without_worker_overlap(self) -> None: """Disk, network, and registry resource conflicts each serialize their owners.""" for resource_class in ( - ResourceClass.DISK_HEAVY, - ResourceClass.NETWORK_HEAVY, - ResourceClass.REGISTRY_SENSITIVE, + ResourceClass.DISK_HEAVY, + ResourceClass.NETWORK_HEAVY, + ResourceClass.REGISTRY_SENSITIVE, ): with ( self.subTest(resource_class=resource_class), diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 1b7f0707..369fbbf3 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -38,12 +38,12 @@ class _RejectingWriter(ArtifactWriter): """Prove cancellation paths never attempt to publish evidence.""" def register_file( - self, - source: Path, - *, - media_type: str = "application/octet-stream", - evidence_kind: EvidenceKind = EvidenceKind.DERIVED, - transformations: tuple[str, ...] = (), + self, + source: Path, + *, + media_type: str = "application/octet-stream", + evidence_kind: EvidenceKind = EvidenceKind.DERIVED, + transformations: tuple[str, ...] = (), ) -> NoReturn: raise AssertionError("a cancelled collector must not register an artifact") @@ -52,10 +52,10 @@ class _NoopLogger(EventLogger): """Discard test events.""" def event( - self, - level: str, - message: str, - **fields: float | str, + self, + level: str, + message: str, + **fields: float | str, ) -> None: return None @@ -172,7 +172,7 @@ def test_shipped_collectors_pass_preflight(self) -> None: ) def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling( - self, + self, ) -> None: """The independent event channels may overlap while retaining separate identities.""" project_root = Path(__file__).resolve().parent.parent @@ -211,7 +211,7 @@ def test_event_log_collectors_explicitly_allow_bounded_parallel_scheduling( ) def test_mode_matrix_represents_every_discovered_collector_exactly_once( - self, + self, ) -> None: """Mode documentation covers selected, manual-only, and quarantined collectors.""" project_root = Path(__file__).resolve().parent.parent @@ -270,7 +270,7 @@ def test_mode_matrix_represents_every_discovered_collector_exactly_once( ) def test_every_core_collector_uses_context_artifacts_without_mutable_globals( - self, + self, ) -> None: """Migration is complete only when all core modules use owned workspaces and catalogs.""" project_root = Path(__file__).resolve().parent.parent @@ -307,14 +307,14 @@ def test_every_core_collector_uses_context_artifacts_without_mutable_globals( for node in ast.walk(tree): if ( - isinstance( - node, - ( - ast.FunctionDef, - ast.AsyncFunctionDef, - ), - ) - and node.name == "collect" + isinstance( + node, + ( + ast.FunctionDef, + ast.AsyncFunctionDef, + ), + ) + and node.name == "collect" ): collect = node break @@ -329,7 +329,7 @@ def test_every_core_collector_uses_context_artifacts_without_mutable_globals( self.assertIn("register_file", attributes) def test_every_core_collector_has_a_stable_output_contract( - self, + self, ) -> None: """Every shipped output has explicit names, formats, package paths, and retention.""" project_root = Path(__file__).resolve().parent.parent @@ -351,7 +351,8 @@ def test_every_core_collector_has_a_stable_output_contract( contract.media_types, ) self.assertTrue(contract.workspace_patterns) - self.assertTrue(all("\\" not in pattern and not pattern.startswith("/") for pattern in contract.workspace_patterns)) + self.assertTrue( + all("\\" not in pattern and not pattern.startswith("/") for pattern in contract.workspace_patterns)) self.assertEqual( len(contract.workspace_patterns), len(contract.package_patterns), @@ -363,7 +364,7 @@ def test_every_core_collector_has_a_stable_output_contract( ) def test_every_core_collector_obeys_the_typed_lifecycle_contract( - self, + self, ) -> None: """All shipped collectors fail closed on cancellation without platform access or artifacts.""" project_root = Path(__file__).resolve().parent.parent @@ -427,7 +428,7 @@ def test_every_core_collector_obeys_the_typed_lifecycle_contract( collector.cleanup(context) def test_every_registered_artifact_media_type_is_declared( - self, + self, ) -> None: """Static artifact calls cannot introduce an undeclared output format.""" project_root = Path(__file__).resolve().parent.parent @@ -453,11 +454,11 @@ def test_every_registered_artifact_media_type_is_declared( continue if ( - isinstance( - node.func, - ast.Attribute, - ) - and node.func.attr == "register_file" + isinstance( + node.func, + ast.Attribute, + ) + and node.func.attr == "register_file" ): register_calls.append(node) @@ -473,7 +474,8 @@ def test_every_registered_artifact_media_type_is_declared( if isinstance(value, ast.Constant) and isinstance(value.value, str): declared_at_calls.add(value.value) - has_implicit_media_type = any(not any(keyword.arg == "media_type" for keyword in call.keywords) for call in register_calls) + has_implicit_media_type = any( + not any(keyword.arg == "media_type" for keyword in call.keywords) for call in register_calls) if has_implicit_media_type: declared_at_calls.add("application/octet-stream") @@ -487,7 +489,7 @@ def test_every_registered_artifact_media_type_is_declared( ) def test_migration_documentation_covers_every_supported_bridge( - self, + self, ) -> None: """Public compatibility stays explicit and canonical-output-only.""" project_root = Path(__file__).resolve().parent.parent @@ -506,9 +508,9 @@ def test_migration_documentation_covers_every_supported_bridge( ) for bridge in ( - "CODE/config.ini", - "core.integration.legacy_code_outputs", - "MODS/", + "CODE/config.ini", + "core.integration.legacy_code_outputs", + "MODS/", ): self.assertIn( bridge, @@ -542,7 +544,7 @@ def test_migration_documentation_covers_every_supported_bridge( ) def test_each_core_module_owns_exactly_one_policy_contract( - self, + self, ) -> None: """Different permission, sensitivity, timeout, or output policies require separate IDs.""" project_root = Path(__file__).resolve().parent.parent @@ -570,8 +572,8 @@ def test_each_core_module_owns_exactly_one_policy_contract( for node in tree.body: if not isinstance( - node, - ast.ClassDef, + node, + ast.ClassDef, ): continue @@ -605,7 +607,7 @@ def test_each_core_module_owns_exactly_one_policy_contract( ) def test_every_core_collector_handles_mocked_windows_platform_responses( - self, + self, ) -> None: """Each collector returns a typed result when Windows adapters report unavailable data.""" project_root = Path(__file__).resolve().parent.parent @@ -760,7 +762,7 @@ def test_every_core_collector_handles_mocked_windows_platform_responses( ) def test_default_profiles_keep_quick_runs_small_and_deep_runs_complete( - self, + self, ) -> None: """Minimal and standard stay bounded while deep explicitly owns the exhaustive surface.""" project_root = Path(__file__).resolve().parent.parent diff --git a/tests/test_test_runner_cli.py b/tests/test_test_runner_cli.py index 4a73fae3..4e6c25df 100644 --- a/tests/test_test_runner_cli.py +++ b/tests/test_test_runner_cli.py @@ -168,7 +168,8 @@ def runTest(self) -> None: patch("logicytics.module.configuration.load_config", return_value=configuration), patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), patch("logicytics.module.logging.get_application_logger", return_value=logger), - patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite((NoisyTest(),))), + patch.object(test_runner.unittest.defaultTestLoader, "discover", + return_value=unittest.TestSuite((NoisyTest(),))), patch("sys.stdout", stdout), patch("sys.stderr", stderr), ): @@ -200,7 +201,8 @@ def runTest(self) -> None: patch("logicytics.module.configuration.load_config", return_value=configuration), patch("logicytics.module.output_layout.ensure_output_layout", return_value=MagicMock()), patch("logicytics.module.logging.get_application_logger", return_value=logger), - patch.object(test_runner.unittest.defaultTestLoader, "discover", return_value=unittest.TestSuite((NoisyTest(),))), + patch.object(test_runner.unittest.defaultTestLoader, "discover", + return_value=unittest.TestSuite((NoisyTest(),))), patch("sys.stdout", stdout), patch("sys.stderr", stderr), ): diff --git a/tests/test_windows_integration.py b/tests/test_windows_integration.py index f0ab6df6..12f128d4 100644 --- a/tests/test_windows_integration.py +++ b/tests/test_windows_integration.py @@ -26,8 +26,8 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: self.assertIn(privilege, {True, False, None}) with registry_adapter.OpenKey( - registry_adapter.HKEY_LOCAL_MACHINE, - r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", + registry_adapter.HKEY_LOCAL_MACHINE, + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", ) as key: product_name, _ = registry_adapter.QueryValueEx(key, "ProductName") self.assertTrue(str(product_name).strip()) @@ -77,7 +77,8 @@ def test_read_only_windows_platform_integrations_are_bounded(self) -> None: sysinternals = [which(name) for name in ("autorunsc", "handle", "pslist")] for executable in (path for path in sysinternals if path is not None): with self.subTest(integration="sysinternals", executable=executable): - result = process_adapter.run([executable, "-?"], capture_output=True, check=False, text=True, timeout=20) + result = process_adapter.run([executable, "-?"], capture_output=True, check=False, text=True, + timeout=20) self.assertIsInstance(result.returncode, int) def test_every_shipped_output_contract_publishes_on_windows(self) -> None: @@ -114,8 +115,8 @@ def test_every_shipped_output_contract_publishes_on_windows(self) -> None: ) for pattern, media_type in zip( - contract.workspace_patterns, - contract.media_types, + contract.workspace_patterns, + contract.media_types, ): parts: list[str] = [] diff --git a/tests/test_wmic_inventory_collector.py b/tests/test_wmic_inventory_collector.py index 26372b45..b97d667c 100644 --- a/tests/test_wmic_inventory_collector.py +++ b/tests/test_wmic_inventory_collector.py @@ -73,9 +73,9 @@ def test_absent_wmic_is_an_explicit_skip(self) -> None: cancellation_file=root / ".cancelled", ) with patch.object( - wmic_inventory, - wmic_inventory.which.__name__, - return_value=None, + wmic_inventory, + wmic_inventory.which.__name__, + return_value=None, ): collector = wmic_inventory.WmicInventoryCollector() validation = collector.validate(context) diff --git a/tests/test_worker_security.py b/tests/test_worker_security.py index 7b4624e8..88d58797 100644 --- a/tests/test_worker_security.py +++ b/tests/test_worker_security.py @@ -56,7 +56,8 @@ def test_collector_cannot_modify_peer_workspace_or_repository_files(self) -> Non ) self.assertEqual("succeeded", records["core.system.z_independent"].status) self.assertFalse((root / "repository-compromised.txt").exists()) - self.assertFalse((outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists()) + self.assertFalse( + (outcome.run_directory / "collectors" / "z_independent" / "compromised.txt").exists()) def test_collector_cannot_mutate_its_process_environment(self) -> None: """A collector must not change process environment or working-directory policy.""" @@ -154,7 +155,7 @@ def test_worker_rejects_undeclared_subprocess_network_and_registry_capabilities( self.assertFalse(retry_safe) def test_worker_enforces_external_browser_sensitive_and_private_key_read_capabilities( - self, + self, ) -> None: """External evidence reads require filesystem access plus every applicable sensitive grant.""" with tempfile.TemporaryDirectory() as temporary: @@ -302,7 +303,7 @@ def test_worker_allows_an_explicitly_declared_subprocess_by_default(self) -> Non self.assertEqual("succeeded", outcome.manifest.collectors[0].status) def test_worker_blocks_application_update_power_and_peer_collector_commands_without_stopping_peers( - self, + self, ) -> None: """Subprocess approval cannot escape the collector role or affect an independent worker.""" with tempfile.TemporaryDirectory() as temporary: @@ -370,7 +371,7 @@ def test_worker_blocks_application_update_power_and_peer_collector_commands_with self.assertFalse(protected_configuration.exists()) def test_preflight_rejects_collector_imports_of_application_and_orchestration_services( - self, + self, ) -> None: """Collectors may import public contracts but never the main application control surface.""" with tempfile.TemporaryDirectory() as temporary: @@ -395,7 +396,8 @@ def test_preflight_rejects_collector_imports_of_application_and_orchestration_se report = preflight(root) self.assertEqual(1, len(report.invalid)) self.assertIn("forbidden application import", "\n".join(report.invalid[0].static_errors)) - diagnostic = next(item for item in report.invalid[0].diagnostics if "forbidden application import" in item.message) + diagnostic = next(item for item in report.invalid[0].diagnostics if + "forbidden application import" in item.message) self.assertEqual("static.engine_boundary", diagnostic.rule) From 89efc672bade50ac786f8d5045d19893f5aeaece Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 11 Sep 2026 01:21:49 +0300 Subject: [PATCH 307/316] docs: removed unnecessary doc + test Signed-off-by: DefinetlyNotAI --- CONTRIBUTING.md | 1 - docs/V4_RELEASE.md | 14 ---- tests/test_release_documentation.py | 111 ---------------------------- 3 files changed, 126 deletions(-) delete mode 100644 docs/V4_RELEASE.md delete mode 100644 tests/test_release_documentation.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c65f5a00..b3bab2fa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,7 +149,6 @@ Keep user and developer documentation synchronized with behavior: - [MODS.md](docs/MODS.md): extension contract. - [MIGRATION.md](docs/MIGRATION.md): supported compatibility boundary. - [FLOW_MATRIX.md](docs/FLOW_MATRIX.md): executable flow evidence. -- [V4_RELEASE.md](docs/V4_RELEASE.md): v4 recreation scope and release verification. The repository wiki is complementary documentation, not a substitute for the versioned contract files required to review a change. diff --git a/docs/V4_RELEASE.md b/docs/V4_RELEASE.md deleted file mode 100644 index 7030a987..00000000 --- a/docs/V4_RELEASE.md +++ /dev/null @@ -1,14 +0,0 @@ -# Project verification record - -This compatibility entry point records the repository's complete recreation and verification history for readers who -arrive through an older link. It is intentionally not a feature guide; the feature and operator documentation lives in -the other pages in this directory. - -The GitHub wiki is published from `docs/` by the repository workflow. Historical verification evidence includes commit -`eaf7628`; verify the commit in the repository before relying on it as current evidence. - -The live contract covers specialties `system`, `hardware`, `process`, `memory`, `filesystem`, `network`, `packet`, -`wireless`, `bluetooth`, `usb`, `browser`, `registry`, `event_log`, `storage`, `encryption`, `media`, `ssh`, -`diagnostics`, `reporting`, and `integration`; modes `quick`, `balanced`, `standard`, `offline`, and `thorough`; and -MIME types `application/json`, `application/octet-stream`, `application/xml`, `application/zip`, `text/csv`, -`text/html`, `text/plain`, and `text/vnd.graphviz`. diff --git a/tests/test_release_documentation.py b/tests/test_release_documentation.py deleted file mode 100644 index 1cfb776b..00000000 --- a/tests/test_release_documentation.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Executable release-documentation and feature-ownership contracts for v4.0.""" - -from __future__ import annotations - -import tomllib -import unittest -from pathlib import Path - -from logicytics.contracts import Specialty -from logicytics.module.discovery import preflight -from logicytics.module.modes import EXECUTION_MODES - - -class ReleaseDocumentationTests(unittest.TestCase): - @classmethod - def setUpClass(cls) -> None: - cls.root = Path(__file__).resolve().parent.parent - cls.readme = (cls.root / "README.md").read_text(encoding="utf-8") - cls.release = (cls.root / "docs" / "V4_RELEASE.md").read_text(encoding="utf-8") - - def test_release_identity_is_final_and_no_provisional_claim_remains(self) -> None: - metadata = tomllib.loads((self.root / "pyproject.toml").read_text(encoding="utf-8")) - self.assertEqual("4.0.0", metadata["project"]["version"]) - self.assertIn("complete recreation", self.release.casefold()) - self.assertIn("verified run", self.readme.casefold()) - for provisional in ( - "being rebuilt", - "early v4 implementation", - "broader collection catalog is being rebuilt", - "to be released", - ): - with self.subTest(provisional=provisional): - self.assertNotIn(provisional, self.readme.casefold()) - self.assertNotIn( - provisional, - (self.root / "SECURITY.md").read_text(encoding="utf-8").casefold(), - ) - - def test_release_covers_every_specialty_mode_and_output_media_type(self) -> None: - report = preflight(self.root) - self.assertEqual((), report.invalid) - core = [candidate for candidate in report.valid if candidate.kind.value == "core"] - self.assertEqual(66, len(core)) - for specialty in Specialty: - with self.subTest(specialty=specialty.value): - self.assertIn(f"`{specialty.value}`", self.release) - for mode in EXECUTION_MODES: - with self.subTest(mode=mode): - self.assertIn(f"`{mode}`", self.release) - media_types = { - media_type for candidate in core if candidate.metadata is not None for media_type in - candidate.metadata.output_media_types - } - for media_type in media_types: - with self.subTest(media_type=media_type): - self.assertIn(f"`{media_type}`", self.release) - - def test_release_evidence_is_recorded(self) -> None: - self.assertIn("GitHub wiki", self.release) - self.assertIn("eaf7628", self.release) - - def test_user_and_contributor_guides_cover_the_release_entry_points(self) -> None: - documents = ( - "CONFIGURATION.md", - "CONTRIBUTING.md", - "OUTPUTS.md", - "MODS.md", - "MIGRATION.md", - "FLOW_MATRIX.md", - "V4_RELEASE.md", - "SECURITY.md", - "CODE_OF_CONDUCT.md", - ) - for document in documents: - with self.subTest(document=document): - location = ( - self.root / "docs" / document - if document.endswith(".md") - and document - not in { - "CONTRIBUTING.md", - "SECURITY.md", - "CODE_OF_CONDUCT.md", - } - else self.root / document - ) - self.assertTrue(location.is_file()) - self.assertIn("Logicytics Wiki", self.readme) - contributor = (self.root / "CONTRIBUTING.md").read_text(encoding="utf-8") - for requirement in ( - "Development setup", - "Architecture boundaries", - "Core collector changes", - "Plugins and MODs", - "Configuration changes", - "Testing expectations", - "conventional commit", - "Developer Certificate of Origin", - ): - with self.subTest(requirement=requirement): - self.assertIn(requirement, contributor) - - def test_output_location_documentation_matches_run_owned_packaging(self) -> None: - migration = (self.root / "docs" / "MIGRATION.md").read_text(encoding="utf-8") - self.assertIn("manifest-backed run folder", self.readme) - self.assertIn("logicytics.yaml", migration) - self.assertIn("logicytics/module/", migration) - - -if __name__ == "__main__": - unittest.main() From 0631c9513570688e16ae2d17d19222ae088a768e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 11 Sep 2026 01:36:42 +0300 Subject: [PATCH 308/316] refactor(extensions): remove legacy MOD support Retire script-sidecar discovery, execution, packaging, and compatibility paths. Document the plugin-only extension contract, command flags, and error remediation. Signed-off-by: DefinetlyNotAI --- CONTRIBUTING.md | 18 +- README.md | 3 +- docs/ARCHITECTURE.md | 1 - docs/COMMANDS.md | 166 ++++++++++++------ docs/CONTRACTS.md | 132 +++++++++------ docs/DEVELOPMENT.md | 2 +- docs/ENGINE.md | 2 +- docs/ERRORS.md | 122 ++++++++++++++ docs/FLOW_MATRIX.md | 3 +- docs/GETTING_STARTED.md | 4 +- docs/MIGRATION.md | 4 +- docs/MODS.md | 15 -- docs/OPERATIONS.md | 8 +- docs/PLUGINS.md | 53 +++--- docs/README.md | 2 +- docs/SAFETY.md | 7 +- docs/TROUBLESHOOTING.md | 4 +- logicytics/cli/commands.py | 14 +- logicytics/contracts.py | 5 +- logicytics/module/api.py | 2 +- logicytics/module/configuration.py | 2 +- logicytics/module/discovery.py | 85 ---------- logicytics/module/interaction.py | 1 - logicytics/module/logging.py | 4 - logicytics/module/mod_runner.py | 86 ---------- logicytics/module/modes.py | 7 +- logicytics/module/output_layout.py | 2 - logicytics/module/packaging.py | 91 +--------- logicytics/module/planner.py | 5 - logicytics/module/runtime.py | 184 +------------------- tests/fixtures/collectors.py | 27 --- tests/test_cli.py | 18 +- tests/test_contracts.py | 9 +- tests/test_flow_matrix.py | 1 - tests/test_logging.py | 3 +- tests/test_mods.py | 262 ----------------------------- tests/test_shipped_collectors.py | 1 - 37 files changed, 398 insertions(+), 957 deletions(-) create mode 100644 docs/ERRORS.md delete mode 100644 docs/MODS.md delete mode 100644 logicytics/module/mod_runner.py delete mode 100644 tests/test_mods.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b3bab2fa..e077c610 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,16 +79,15 @@ responses, cancellation coverage, and output-contract evidence. If structured bytes change intentionally, update the relevant golden file in `tests/golden/` and explain why. -## Plugins and MODs +## Plugins -Plugins implement the typed `PluginCollector` contract and remain opt-in. MODs -use a sidecar declaration and may be Python, PowerShell, batch, or executable -payloads. Neither extension type may bypass preflight, planning, capability -approval, isolated workspaces, artifact registration, or package filtering. +Plugins implement the typed `PluginCollector` contract and remain opt-in. They +may not bypass preflight, planning, capability approval, isolated workspaces, +artifact registration, or package filtering. -Read [MODS.md](docs/MODS.md) before changing discovery or extension behavior. Read -[MIGRATION.md](docs/MIGRATION.md) before changing a legacy flag, schema migration, -historical `CODE` evidence import, or MOD adapter. Compatibility code must remain +Read [PLUGIN_AUTHORING.md](docs/PLUGIN_AUTHORING.md) before changing discovery or +extension behavior. Read [MIGRATION.md](docs/MIGRATION.md) before changing a legacy +flag, schema migration, or historical `CODE` evidence import. Compatibility code must remain a bounded translation into the canonical v4 model. ## Configuration changes @@ -98,7 +97,7 @@ Configuration changes must preserve the strict schema in parser-backed tests in the same commit as any setting, default, bound, migration alias, or source-precedence change. -Profiles, modes, include/exclude selections, plugin/MOD enablement, capability +Profiles, modes, include/exclude selections, plugin enablement, capability approval, authorization acknowledgement, scheduling overrides, reruns, package policy, and post-run power actions are invocation-only `RunRequest` behavior. They do not belong in persistent configuration. @@ -146,7 +145,6 @@ Keep user and developer documentation synchronized with behavior: troubleshooting. - [CONFIGURATION.md](docs/CONFIGURATION.md): every persistent setting and migration. - [OUTPUTS.md](docs/OUTPUTS.md): artifact and retention contracts. -- [MODS.md](docs/MODS.md): extension contract. - [MIGRATION.md](docs/MIGRATION.md): supported compatibility boundary. - [FLOW_MATRIX.md](docs/FLOW_MATRIX.md): executable flow evidence. diff --git a/README.md b/README.md index 66afa0a1..c2631523 100644 --- a/README.md +++ b/README.md @@ -125,12 +125,11 @@ Use `python -m logicytics --help` or append `--help` to any command for its full `logicytics.yaml` is the single user configuration file. It controls output locations, worker limits, logging, optional Sysinternals setup, and declared collector settings. Keep credentials and secrets out of it. -Core collectors are shipped and validated as part of the application. Plugins and MODs are opt-in and must pass the same +Core collectors are shipped and validated as part of the application. Plugins are opt-in and must pass the same validation boundary before they can run. - [Configuration reference](docs/CONFIGURATION.md) - [Output contract](docs/OUTPUTS.md) -- [MODS guide](docs/MODS.md) - [Migration guide](docs/MIGRATION.md) - [Flow matrix](docs/FLOW_MATRIX.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 78e8eb4d..1daca8f1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -7,7 +7,6 @@ | `logicytics/` | Public package, contracts, CLI, configuration, discovery, planning, runtime, logging, manifests, packaging, and platform adapters | | `core/` | Shipped read-only collector implementations grouped by specialty | | `plugins/` | Opt-in user-owned `PluginCollector` implementations | -| `MODS/` | Opt-in Python scripts enabled by metadata sidecars | | `tests/` | Unit, contract, resilience, security, and Windows integration tests | | `docs/` | This user, operator, and developer manual | | `output/` | Local run output; do not commit evidence | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 84f575aa..2a2afae5 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1,114 +1,170 @@ # Command reference -The installer is the only command intended to run outside the managed environment. All other examples use the explicit -interpreter. - -## Global form +Run commands from the repository root. Except for the installer, use the managed interpreter exactly as shown: ```text -python -m logicytics [global options] [command options] +.\.venv\Scripts\python.exe -m logicytics [global options] [command options] ``` -Global options: +An option may appear before or after a command when the command supports it. Repeated options may be supplied more than once. `plan` is the safe checkpoint: it resolves scope without collecting evidence. + +## Global options -| Option | Meaning | -|-----------------|--------------------------------------------------------------------------------------| -| `--config PATH` | Use a specific authoritative YAML file | -| `--usb [DRIVE]` | Locate a Windows installation on removable storage; optionally choose a drive letter | -| `--usage` | Show local interaction statistics and create a usage graph | -| `--modes` | Show the typed execution-mode inclusion matrix | -| `--match TEXT` | Suggest the closest documented action for natural-language text | -| `-h`, `--help` | Show help | +| Flag | What it does | +| --- | --- | +| `--config PATH` | Selects the authoritative YAML configuration. The path is validated before planning. | +| `--usb [DRIVE]` | Targets a Windows installation on removable storage; omit `DRIVE` to scan, or provide a drive letter. | +| `--usage` | Displays local interaction statistics and writes the usage graph. | +| `--modes` | Prints and writes the generated execution-mode inclusion matrix. | +| `--match TEXT` | Suggests the closest documented action from a natural-language description. | +| `-h`, `--help` | Shows parser help without collecting evidence. | ## Installer ```powershell -python -m logicytics.cli.installer +python -m logicytics.cli.installer [--environment PATH] [--overwrite-config] ``` -The menu prepares the virtual environment and root YAML. It is intentionally separate from collection. +The installer is the only entry point intended for a system Python. It creates or reuses the virtual environment and creates `logicytics.yaml` if absent. + +| Flag | What it does | +| --- | --- | +| `--environment PATH` | Uses this virtual-environment directory instead of `.venv`; a relative path is resolved from the repository root. | +| `--overwrite-config` | Replaces the root configuration template. Back up a customized file first. | ## `preflight` ```powershell -python -m logicytics preflight [options] +.\.venv\Scripts\python.exe -m logicytics preflight [flags] ``` -Checks all selected source trees and prerequisites without running collection. Options: `--profile`, -`--invalidate-cache`, repeated `--include ID`, repeated `--exclude ID`, `--plugins`, `--mods`, repeated -`--block-capability CAP`, `--config`, and `--usb [DRIVE]`. +Validates core and, when enabled, plugin sources before collection. It reports valid collectors, quarantined invalid optional plugins, and blocking selected failures. + +| Flag | What it does | +| --- | --- | +| `--config PATH`, `--usb [DRIVE]` | Applies the global configuration or removable-media source selection. | +| `--profile MODE` | Uses one named mode for selected-plugin validation context. | +| `--include ID` | Repeats to select exact collector IDs; selected invalid plugins become blocking failures. | +| `--exclude ID` | Repeats to remove IDs from the request. | +| `--plugins` | Enables valid `PluginCollector` implementations from `plugins/` for the request. | +| `--invalidate-cache` | Discards cached validation metadata and performs a fresh source/runtime probe. | +| `--workers COUNT` | Supplies the bounded worker count carried into request validation. | +| `--block-capability CAP` | Repeats to prohibit a declared capability. See [Safety](SAFETY.md). | ## `plan` ```powershell -python -m logicytics plan --mode MODE [options] +.\.venv\Scripts\python.exe -m logicytics plan --mode standard [flags] ``` -Creates and prints a plan without starting workers. Options are `--mode {quick,balanced,standard,offline,thorough}`, -`--profile`, `--invalidate-cache`, `--include`, `--exclude`, `--plugins`, `--mods`, `--workers COUNT`, repeated -`--block-capability`, `--config`, and `--usb`. +Builds and prints a deterministic, dependency-ordered plan. It starts no collector worker and writes no evidence artifacts. + +| Flag | What it does | +| --- | --- | +| `--mode {quick,balanced,standard,offline,thorough}` | Selects the request mode and its scheduling behavior. | +| `--profile MODE` | Alternative named mode selection. Do not combine it with `--mode`. | +| `--include ID` / `--exclude ID` | Repeats to refine the resolved profile with exact dotted collector IDs. | +| `--plugins` | Allows valid opt-in plugins to be considered; plugins remain opt-in even when their profile matches. | +| `--invalidate-cache` | Revalidates collector metadata before planning. | +| `--workers COUNT` | Sets a positive bounded worker count for the resulting request. | +| `--block-capability CAP` | Rejects a plan containing a collector that declares the capability. | +| `--config PATH`, `--usb [DRIVE]` | Selects configuration or removable-media context. | ## `run` ```powershell -python -m logicytics run --mode MODE [options] +.\.venv\Scripts\python.exe -m logicytics run --mode standard --acknowledge-authorization [flags] ``` -The mode options are mutually exclusive: `--mode MODE`, `--default`, `--threaded`, `--minimal`, or `--depth`. -`--profile` is the named profile form and should not conflict with the mode selection. - -All run options: `--include ID`, `--exclude ID`, `--plugins`, `--mods`, `--workers COUNT`, `--block-capability CAP`, -`--rerun-from PATH`, `--sequential`, `--parallel`, `--performance-check`, `--no-package`, `--reboot`, `--shutdown`, -`--acknowledge-authorization`, `--interactive`, `--config`, and `--usb`. - -`--reboot` and `--shutdown` are mutually exclusive. `--sequential` and `--parallel` are mutually exclusive. Post-run -power actions require packaged output. +Runs the reviewed plan in isolated workers. Read the final manifest even after an exit code of `0`; a package is not proof that every collector succeeded. + +| Flag | What it does | +| --- | --- | +| `--mode MODE` | Selects a named execution mode. | +| `--default`, `--threaded`, `--minimal`, `--depth` | Historical aliases for `standard`, `balanced`, `quick`, and `thorough`; exactly one mode selector is allowed. | +| `--profile MODE` | Alternative named mode selector; cannot be combined with a mode selector. | +| `--include ID` / `--exclude ID` | Repeats to add or remove exact collector IDs. | +| `--plugins` | Enables valid opt-in plugins for this run. | +| `--workers COUNT` | Caps concurrent isolated workers. Sequential work requires `1`; parallel work requires at least `2`. | +| `--sequential` / `--parallel` | Mutually exclusive scheduling override. Use sequential execution for deterministic diagnosis. | +| `--performance-check` | Forces serial measurement and writes per-collector duration evidence. It cannot be combined with `--parallel`. | +| `--block-capability CAP` | Repeats to prohibit declared access categories. | +| `--rerun-from PATH` | Re-runs explicitly included IDs from a finalized compatible manifest or run directory. | +| `--no-package` | Finalizes the manifest-backed run directory without writing a ZIP package. | +| `--reboot` / `--shutdown` | Mutually exclusive post-run host actions, scheduled only after verified package publication. | +| `--acknowledge-authorization` | Required confirmation that the operator has authority for the reviewed scope. | +| `--interactive` | Keeps the interactive command window open after final status. | +| `--config PATH`, `--usb [DRIVE]` | Selects configuration or removable-media context. | ## `collector` ```powershell -python -m logicytics collector core.system.system_info --acknowledge-authorization +.\.venv\Scripts\python.exe -m logicytics collector core.system.system_info --acknowledge-authorization [flags] ``` -Runs one exact collector ID independently. It accepts `--profile`, repeated `--include`/`--exclude`, `--plugins`, -`--mods`, `--workers`, repeated `--block-capability`, `--no-package`, `--acknowledge-authorization`, `--interactive`, -`--config`, and `--usb`. +Runs one exact collector ID independently. It is the preferred way to diagnose one source after reviewing its capabilities. + +| Flag | What it does | +| --- | --- | +| `COLLECTOR_ID` | Required positional dotted ID for the one collector to run. | +| `--acknowledge-authorization` | Required authorization confirmation. | +| `--no-package` | Keeps the manifest-backed result without a ZIP. | +| `--interactive` | Keeps an interactive command window open at completion. | +| `--workers COUNT` | Sets the bounded worker limit; the direct collector still resolves one selected source. | +| `--block-capability CAP` | Rejects the collector when it declares a blocked capability. | +| `--config PATH`, `--usb [DRIVE]` | Selects configuration or removable-media context. | + +`collector` cannot be combined with `--profile`, `--include`, `--exclude`, or `--plugins`; its positional ID is the complete selection. ## `debug` ```powershell -python -m logicytics debug +.\.venv\Scripts\python.exe -m logicytics debug [flags] ``` -Writes a diagnostic JSON report containing environment, Python, configuration, maintenance, optional tool, and preflight -information. It accepts the common config and USB options plus selection and capability options. +Writes a bounded diagnostic JSON report containing environment, interpreter, configuration, maintenance, optional-tool, and preflight information. Sanitize it before sharing. + +| Flag | What it does | +| --- | --- | +| `--config PATH`, `--usb [DRIVE]` | Uses the selected configuration and target context while gathering diagnostics. | +| `--profile MODE`, `--include ID`, `--exclude ID`, `--plugins` | Applies the request selection used for preflight diagnostics. | +| `--workers COUNT`, `--block-capability CAP` | Carries execution and policy context into the diagnostic request. | ## `update` ```powershell -python -m logicytics update [--apply] [--launch-action { preflight, debug, dev }] [--new-window] +.\.venv\Scripts\python.exe -m logicytics update [--apply] [--launch-action ACTION --new-window] ``` -Checks Git and remote reachability. `--apply` explicitly runs `git pull`. `--launch-action` must be paired with -`--new-window`; the action is allowlisted. +Checks Git, the repository, origin configuration, and remote reachability. It never pulls unless `--apply` is explicit. + +| Flag | What it does | +| --- | --- | +| `--apply` | Runs `git pull` after successful repository and remote checks. | +| `--launch-action {preflight,debug,dev}` | Selects an allowlisted maintenance action to launch after update. Requires `--new-window`. | +| `--new-window` | Starts the allowlisted action in a visible separate Windows console. | +| `--config PATH`, `--usb [DRIVE]` | Accepts common context flags; update behavior itself operates on the checkout. | ## `dev` ```powershell -python -m logicytics dev [--write-manifest] [--next-version VERSION] [--interactive] +.\.venv\Scripts\python.exe -m logicytics dev [--write-manifest --next-version VERSION] [--interactive] ``` -Runs developer integrity and contribution checks. Writing the local integrity manifest requires a valid semantic version -and the explicit write option. - -## Modes and profiles +Runs repository integrity and organization checks. It is a developer maintenance command, not an evidence collection command. -The available named modes are `quick`, `balanced`, `standard`, `offline`, and `thorough`. Historical aliases are -`--default`, `--threaded`, `--minimal`, and `--depth`. Use `--modes` to inspect the live inclusion matrix; do not guess -which collector belongs to a profile. +| Flag | What it does | +| --- | --- | +| `--write-manifest` | Writes the reviewed local integrity manifest; requires `--next-version` unless interactive mode supplies it. | +| `--next-version VERSION` | Provides the semantic version recorded in a newly written manifest. | +| `--interactive` | Shows checks and prompts before an integrity-manifest write. | +| `--config PATH`, `--usb [DRIVE]` | Accepts common context flags. | ## Exit codes -`0` means the requested command completed successfully. `1` means a collection completed with a non-success run status. -`2` means command, configuration, preflight, or planning failure. `130` means interruption. Always use the manifest and -diagnostic JSON to understand a non-zero result. +| Code | Meaning | Next action | +| --- | --- | --- | +| `0` | Command completed successfully. | Review the manifest or generated report. | +| `1` | A collection finished with non-success status. | Preserve the manifest and inspect partial, skipped, failed, or cancelled records. | +| `2` | Argument, configuration, preflight, planning, or command error. | Read the rendered error and [Error reference](ERRORS.md); correct the cause rather than bypassing validation. | +| `130` | Interrupted. | Locate the durable manifest and determine what safely finalized before retrying. | diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 36616995..2f40dad0 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -1,73 +1,103 @@ -# Engine contracts +# Engine and plugin contracts -The contracts in `logicytics/contracts.py` are the stable vocabulary shared by the engine and collectors. +`logicytics/contracts.py` is the stable vocabulary shared by the CLI, planner, runtime, core collectors, and the only supported extension type: `PluginCollector`. A contract violation is intentionally rejected during construction, preflight, planning, worker execution, artifact registration, or packaging rather than being silently repaired. -## Metadata +## Identity and ownership -`CollectorMetadata` declares `id`, `name`, semantic `version`, `specialty`, description, author, supported platforms, -capabilities, sensitive-data categories, dependencies, default profiles, timeout, memory/output/artifact limits, -retries, minimum contract version, parallel safety, and resource class. Metadata is declarative: constructing it must -not collect data or write files. +Core IDs use `core..`; plugin IDs use `plugin.`. IDs are lowercase dotted identifiers, while labels such as profiles and categories are lowercase snake case. A core module must agree with its `core//.py` location. A plugin must agree with its plugin file or `main.py` folder ownership. -IDs are `core..`, `plugin.` or `mod.`. Labels are lowercase snake case. Capabilities and -output MIME types must be declared exactly. +`CollectorKind` has only `core` and `plugin`. Plugins may use a custom lowercase specialty; core collectors must use the closed `Specialty` enum. All metadata construction must be deterministic and side-effect-free. -## Lifecycle +## `CollectorMetadata` -The collector interface is: +Metadata is policy, not display text. It is evaluated before a collector may enter a plan. -```python -class ExampleCollector(PluginCollector): - @classmethod - def metadata(cls) -> CollectorMetadata: - ... +| Field | Requirement and effect | +| --- | --- | +| `id`, `name`, `description`, `author` | Non-empty single-line strings. `id` must match its owner and location. | +| `version` | Semantic version, for example `1.2.0`. | +| `specialty` | A core `Specialty` value or a plugin's custom lowercase category. | +| `supported_platforms` | Non-empty tuple of lowercase platform labels; a mismatched current platform rejects the plan. | +| `capabilities` | Tuple of declared `Capability` values. Declared access is visible to planning and can be blocked. | +| `privilege_level` | `standard` or `elevated`; it must agree exactly with the `elevated_privileges` capability. | +| `network_access` | `none`, `local`, or `remote`; `network` capability requires local or remote access. | +| `sensitive_data_categories` | Lowercase labels describing sensitive output. Minimal mode cannot include a sensitive collector. | +| `default_profiles` | Non-empty mode-profile membership. Standard mode admits only routine configuration categories. | +| `dependencies` | Unique collector IDs, never the collector's own ID. Dependencies are ordered and policy-checked. | +| `output_media_types` | Non-empty unique MIME types. Every registered artifact must use one of these types. | +| `timeout_seconds` | Positive worker time limit. | +| `maximum_memory_bytes`, `maximum_output_bytes` | Positive per-collector resource ceilings. | +| `maximum_artifact_bytes`, `maximum_artifact_files` | Positive artifact limits; a missing per-artifact byte limit defaults to the output limit and cannot exceed it. | +| `maximum_retries`, `retry_delay_seconds` | Bounded retry policy: retries are 0–3 and delay is finite from 0–30 seconds. | +| `minimum_contract_version` | A valid `major.minor` contract version; the current contract is `4.0`. | +| `parallel_safe`, `resource_class` | Scheduling declaration. A resource class prevents unsafe overlap with conflicting work. | - def validate(self, context: CollectorContext) -> ValidationResult: - ... +### Capability values - @staticmethod - def prepare(context: CollectorContext) -> ValidationResult: - ... +| Capability | Declare it when the collector needs to | +| --- | --- | +| `filesystem_read` / `filesystem_write` | Read host files or write outside its supplied private workspace. | +| `registry_read` | Read Windows registry state. | +| `subprocess` | Invoke a host executable or shell-free command. | +| `network` / `packet_capture` | Reach the network or capture packet metadata. | +| `browser_data`, `sensitive_files`, `private_keys` | Handle those sensitive data categories. | +| `elevated_privileges` | Require an administrator context; metadata must also set `privilege_level=elevated`. | - def collect(self, context: CollectorContext) -> CollectorResult: - ... +The planner rejects selected collectors with a request-blocked capability. Offline mode additionally rejects `network` and `packet_capture` collectors. Capability declaration does not grant permission to evade the worker boundary. - @staticmethod - def finalize(context: CollectorContext, result: CollectorResult) -> CollectorResult: - ... +## Collector lifecycle +```python +class ExamplePluginCollector(PluginCollector): + @classmethod + def metadata(cls) -> CollectorMetadata: ... + def validate(self, context: CollectorContext) -> ValidationResult: ... @staticmethod - def estimate(context: CollectorContext) -> CollectionEstimate: - ... - + def prepare(context: CollectorContext) -> ValidationResult: ... + def collect(self, context: CollectorContext) -> CollectorResult: ... + @staticmethod + def finalize(context: CollectorContext, result: CollectorResult) -> CollectorResult: ... + @staticmethod + def estimate(context: CollectorContext) -> CollectionEstimate: ... @classmethod - def dependencies(cls) -> tuple[str, ...]: - ... - - def cleanup(self, context: CollectorContext) -> None: - ... + def dependencies(cls) -> tuple[str, ...]: ... + def cleanup(self, context: CollectorContext) -> None: ... ``` -`validate` is side-effect-free and runs before authorization and collection. `prepare` is for collector-local setup. -`collect` performs bounded work. `finalize` returns the publishable result. `cleanup` releases collector resources; the -engine owns worker and filesystem cleanup. +`metadata` is declarative. `validate` is side-effect-free and checks prerequisites. `prepare` performs bounded collector-local setup. `collect` does the bounded evidence work. `finalize` returns the publishable result. `cleanup` releases collector-owned resources even when work failed; it must not delete engine-owned run data. `estimate` and `dependencies` are optional hooks when meaningful. + +Preflight also enforces the public class shape. Do not add import-time work, dynamic code loading, hidden public methods, global mutable run state, nested worker pools, `print`-driven control flow, or collector-to-collector calls. + +## Context, results, and progress + +`CollectorContext` supplies the run ID, collector ID, private `workspace`, private `temporary_directory`, configuration settings, artifact writer, event logger, and cancellation state. Use the typed setting helpers (`setting_int`, `setting_float`, and `setting_str`) rather than coercing arbitrary values. Check `context.is_cancelled` before and during every traversal, copy, query, capture, loop, or long subprocess. + +`CollectorResult` uses one terminal `CollectorStatus`: + +| Status | Use it when | +| --- | --- | +| `succeeded` | The requested work completed and registered its declared output. | +| `partial` | Useful registered evidence exists, but a meaningful portion could not complete. | +| `skipped` | A normal prerequisite, device, feature, or permission is unavailable. | +| `cancelled` | The engine or user cancellation boundary was reached. | +| `failed` | The collector encountered an execution or contract error. | + +Always provide a non-empty summary. Attach structured errors and scalar metrics where they aid review. Do not describe a partial or skipped result as success. -## Context and artifacts +## Artifact contract -`CollectorContext` exposes `run_id`, `collector_id`, private `workspace`, private `temporary_directory`, `artifacts`, -`logger`, settings, and `is_cancelled`. Use `setting_int`, `setting_float`, and `setting_str` for safe configuration -reads. Use `report_progress` for meaningful counters and check `is_cancelled` inside loops. +`context.artifacts.register_file(...)` is the only publication path. The source must be complete, non-empty, beneath the worker workspace, owned by the current collector, and use a declared MIME type. The writer validates its path, content size, SHA-256, file-count and byte quotas, and provenance before placing it under the run-owned artifact store. -`ArtifactWriter.register_file(source, media_type, evidence_kind, transformations)` is the only publication path. The -source must be inside the worker workspace. Register only complete, non-empty files with the declared MIME type. Never -hand the engine an arbitrary absolute destination. +An `Artifact` contains a stable ID, POSIX relative path, lowercase SHA-256, size, MIME type, collector ID, source category, timezone-aware collection timestamp, transformations, evidence kind, safe filename, and `registered` status. Artifact paths cannot escape the owner directory; packages are built only from the finalized manifest catalog. -`CollectorResult` contains a terminal status, non-empty summary, artifacts, errors, and scalar metrics. Return `partial` -when useful evidence exists but the collector could not complete fully; return `skipped` when a prerequisite is absent; -return `failed` for an execution error. +## Plugin author checklist -## Validation rules +1. Put one well-named `PluginCollector` implementation under `plugins/`. +2. Make metadata complete, honest, deterministic, and compatible with contract `4.0`. +3. Make validation explain absent prerequisites without collecting data. +4. Bound every loop, file, command, memory allocation, and output path. +5. Use platform adapters and the provided context rather than direct global host access. +6. Register only complete declared artifacts, return a typed result, and clean up collector-owned resources. +7. Add focused tests, run `preflight --plugins`, then inspect the plan before enabling the plugin in a collection. -No import-time subprocesses, network requests, registry writes, collection, or output writes. Keep imports within the -supported boundary. Do not use dynamic code loading to evade preflight. The static validator and isolated runtime probe -are part of the contract, not optional review steps. +See [Plugin Authoring](PLUGIN_AUTHORING.md) for a minimal example and [Errors](ERRORS.md) for remediation of contract failures. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2d1f1002..c3d43938 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -8,7 +8,7 @@ changing engine or collector code. Keep the standard-library-first installer pat ## Change boundaries Engine changes belong under `logicytics/module/`; contract changes belong in `logicytics/contracts.py`; shipped -collection belongs under the matching `core//` directory; user extensions belong in `plugins/` or `MODS/`; +collection belongs under the matching `core//` directory; user extensions belong in `plugins/`; documentation changes belong in `docs/`. Do not commit collected evidence, caches, credentials, or local manifests. ## Adding a core collector diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 7416c1c6..e4c26e47 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -9,7 +9,7 @@ request -> configuration -> preflight -> plan -> isolated workers -> artifacts - ## 1. Request The CLI or Python API creates an immutable `RunRequest`. It contains the profile, exact includes/excludes, plugin and -MOD switches, worker limit, blocked capabilities, performance policy, rerun source, output policy, and optional post-run +plugin enablement, worker limit, blocked capabilities, performance policy, rerun source, output policy, and optional post-run action. Invalid combinations are rejected before collection. ## 2. Configuration diff --git a/docs/ERRORS.md b/docs/ERRORS.md new file mode 100644 index 00000000..c5b60d67 --- /dev/null +++ b/docs/ERRORS.md @@ -0,0 +1,122 @@ +# Error reference + +This reference explains every user-facing error family emitted by the CLI, planner, preflight, runtime, artifact writer, package publisher, and public API. The final detail can include a Windows error, file path, collector ID, or exception text from the host; preserve that detail in the manifest or sanitized diagnostic because it identifies the exact failing source. + +## Read an error before retrying + +1. Record the command, exit code, and exact rendered message. +2. For a collection, open the referenced `manifest.json`; a non-success run can retain valid registered artifacts. +3. Read the error family below, correct the underlying input, scope, capability, source, or environment condition. +4. Re-run `preflight` or `plan` before re-authorizing collection. Do not bypass a gate by changing a status or deleting diagnostic files. + +## CLI and request errors + +| Rendered error or family | Meaning | Correction | +|----------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| `unrecognized arguments` / parser usage | A flag is unsupported, misspelled, or placed after an incompatible command. | Use the supported tables in [Commands](COMMANDS.md). | +| `--profile cannot be combined with a named collection mode` | Two mode selectors were supplied. | Use one of `--profile`, `--mode`, or a legacy mode alias. | +| `--mode cannot be combined with a legacy mode alias` / `legacy collection mode aliases are mutually exclusive` | Conflicting mode selectors were supplied. | Select exactly one mode. | +| `performance checking requires sequential execution` | `--performance-check` was combined with parallel scheduling. | Remove `--parallel`; performance measurement is serial. | +| `sequential execution requires --workers=1` | A sequential request asks for more than one worker. | Omit `--workers` or set it to `1`. | +| `parallel execution requires at least two configured workers` | Parallel execution cannot run with fewer than two workers. | Increase the configured or explicit worker count. | +| `collector execution cannot combine its ID with profile or selection flags` | Direct `collector ID` selection was mixed with profile/include/exclude/plugin selection. | Run only the positional ID, or use `plan`/`run` for a multi-collector request. | +| `--rerun-from requires at least one explicit --include collector ID` | A rerun has no precise source selection. | Supply one or more `--include` IDs from the original finalized plan. | +| `original run manifest cannot be loaded` | The supplied rerun path is unreadable, not JSON, or not a manifest. | Use the prior run directory or its `manifest.json`; preserve the original file. | +| `original run manifest must ...` / `uses unsupported schema_version` | The rerun manifest is malformed, unfinished, incompatible, or lacks a valid resolved plan. | Rerun from a finalized compatible v4 manifest. | +| `rerun collectors were not present in the original run` | An include ID was not part of the prior resolved plan. | Choose only IDs recorded by that manifest, or make a new reviewed request. | +| `must run inside a virtual environment` | The normal CLI was invoked with system Python. | Run the installer, then use `.\.venv\Scripts\python.exe -m logicytics ...`. | +| `Command cancelled` / exit `130` | The process received an interrupt. | Locate the manifest, inspect finalized records, and retry only if scope remains authorized. | + +## Configuration errors + +Configuration failures are `PlanError` conditions and exit before collection. They protect a single authoritative YAML configuration. + +| Error family | Meaning | Correction | +|-----------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------| +| `configuration file ...` / `cannot read configuration` | The selected file is missing, unreadable, oversized, or not valid UTF-8. | Use an accessible YAML file below the size limit. | +| `configuration must contain ...` / `unknown configuration key` | The root or nested mapping has the wrong shape or an unsupported key. | Compare the file against [Configuration](CONFIGURATION.md); remove typos rather than relying on ignored keys. | +| `duplicate key` | YAML repeats a key, making intent ambiguous. | Keep one value for the key. | +| `unsupported configuration schema_version` | The file is not schema version `4`. | Migrate deliberately to the documented schema. | +| `must be ...` / `must not ...` for runtime, logging, interaction, or maintenance fields | A value has the wrong type, range, label, URL, digest, or path form. | Use the field's documented type and bound; do not coerce booleans into numbers. | +| `path escapes project` / `unsafe path` | A configured relative or absolute path leaves its approved root or uses a forbidden location. | Choose a permitted project-relative output, manifest, dump, or temporary path. | +| `collector settings ...` | A core collector received unknown, missing, or out-of-range settings. | Use only that collector's settings and bounds from [Configuration](CONFIGURATION.md). | + +## Preflight errors + +Preflight combines static source checks and an isolated runtime metadata probe. A core failure always blocks. An invalid plugin is quarantined until explicitly selected or enabled, at which point it blocks. + +| Diagnostic family | Meaning | Correction | +|------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------| +| `collector preflight failed` | One or more selected/core candidates are invalid. The appended path and diagnostics identify them. | Fix each named source, then run `preflight --invalidate-cache`. | +| `unsafe filename`, `forbidden top-level statement`, `import-time ...` | Source layout or import behavior would perform work before isolation. | Use a regular lowercase Python module and move work into lifecycle methods. | +| `collector class requires a docstring` / `metadata must construct one CollectorMetadata` | The source does not expose the required inspectable class shape. | Follow [Contracts](CONTRACTS.md) and [Plugin Authoring](PLUGIN_AUTHORING.md). | +| `plugin metadata must explicitly declare ...` | Plugin metadata omits a required security, quota, or output field. | Declare every named field honestly in `metadata()`. | +| `invalid validation response` / `collector contract version is unsupported` | The isolated probe returned invalid metadata or an unsupported contract. | Correct `metadata()` and use minimum contract version `4.0`. | +| `collector ID must start with its owner kind` / location mismatch | Metadata ID does not match a core/plugin module's ownership. | Rename the module, folder, or ID so they agree. | +| `preflight cache ...` | Cached validation state is unreadable or stale. | Run with `--invalidate-cache`; never edit the cache to force validity. | + +## Planning and policy errors + +| Error family | Meaning | Correction | +|------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------| +| `unknown collection profile` | The request profile is not one of the built-in profiles. | Use the modes shown by `--modes`. | +| `max_workers must be positive` / `request max_workers ...` | Worker count is outside its supported positive bound. | Set a valid count and honor sequential/parallel constraints. | +| `requested collectors are unavailable` | An include ID was not discovered as valid. | Check the exact ID, plugin enablement, preflight result, and configuration. | +| `selected collector depends on ...` | A dependency is unavailable, excluded, or forms a cycle. | Restore the dependency, remove the exclusion, or correct metadata dependencies. | +| `plugin dependency ... must be explicitly selected or plugins enabled` | A selected collector depends on an opt-in plugin that was not enabled. | Review and enable plugins, or explicitly include the dependency. | +| `selected collector policy validation failed` | The detailed rows identify blocked capability, unsupported platform, or offline-policy conflict. | Reduce scope, remove the block only when authorized, or use a supported host. | +| `selected collectors require an administrator account` | Metadata requires elevation and the process is not elevated. | Use the normal approved administrative process, or exclude the collector. | +| `authorization acknowledgement is required` | A state-changing collection request omitted explicit authorization acknowledgement. | Review the plan, then add `--acknowledge-authorization` if authorized. | + +## Metadata and contract errors + +These `ValueError` messages are emitted during metadata, request, artifact, estimate, or result construction. They identify a programmer or plugin-authoring contract failure, not a condition to suppress. + +| Error family | Meaning | Correction | +|--------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------| +| `metadata ... must be a non-empty single-line string` | Required identity text is missing or contains a newline. | Provide one non-empty single-line value. | +| `metadata id/specialty/version/minimum_contract_version has an invalid schema` | Identity/version format is invalid. | Use supported core/plugin ID, specialty, semantic version, and `major.minor` contract format. | +| `metadata ... must be a tuple ...` / `must not contain duplicates` | A metadata sequence has the wrong type, invalid labels, or duplicates. | Use immutable tuples of valid unique values. | +| `sensitive collectors must not belong to the minimal profile` | Sensitive evidence was placed in the smallest profile. | Remove `minimal` from the profile list. | +| `standard collectors may contain only routine ... categories` | Standard mode was assigned sensitive categories outside its approved set. | Move it to a narrower/deeper explicit profile. | +| `privilege_level must match ...` / `network_access must declare ...` | Access declarations contradict their capability. | Align elevation/network values with capability declarations. | +| `maximum_artifact_bytes must not exceed maximum_output_bytes` | An individual artifact could exceed total collector output. | Lower the artifact ceiling or increase the total bound responsibly. | +| `request include and exclude selections must not overlap` | The same collector was simultaneously selected and removed. | Keep it on one side only. | +| `request ... must be boolean` / `must be a tuple of ...` | A programmatic `RunRequest` uses wrong types. | Construct the immutable request with documented types. | +| `artifact ...` | Artifact identity, hash, timestamp, MIME type, owner, path, or transformations are invalid. | Publish through the supplied writer; do not manually forge artifacts. | +| `collector result ...` / `estimated_...` | A collector returned an invalid status, summary, result shape, or estimate. | Return a typed constructor result with non-empty summary and valid metrics. | + +## Runtime, artifact, and capability errors + +| Error family | Meaning | Correction | +|----------------------------------------------------|-------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------| +| `CAPABILITY_BLOCKED` | A declared capability is prohibited by request or configuration policy. | Keep the collector out of the plan or remove the block only after authorization review. | +| `CAPABILITY_UNDECLARED` | Collector code attempted host access it did not declare. | Correct metadata and implementation; do not add capability declarations merely to silence the failure. | +| `artifact source ...` / `artifact ... escapes ...` | A source, destination, symlink, or owner path violates workspace confinement. | Produce files under the provided workspace and register them through the writer. | +| `artifact source changed during registration` | The file changed while being copied and hashed. | Finish writing before registration; use an atomic collector-local staging pattern. | +| `artifact ... exceeds ... limit` | Per-file, artifact-count, reserved-output, or run-output quota was exceeded. | Reduce collection or define a justified bounded output policy. | +| `collector worker crashed` | The isolated worker raised unexpectedly. | Read the collector record's error and trace; fix the source or prerequisite, then preflight. | +| `collector timed out`, `memory limit`, `cancelled` | Runtime containment ended the work. | Reduce scope, use sequential diagnosis, and correct the bounded collector behavior. | +| `unsupported collector execution type` | Internal worker payload was not a supported collector payload. | This is an engine bug; retain diagnostics and report it with sanitized context. | + +## Manifest, package, and API errors + +| Error family | Meaning | Correction | +|--------------------------------------------------------------------------------------|------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------| +| `run manifest is missing schema_version` / `unsupported run manifest schema_version` | A caller tried to read an incompatible or malformed manifest. | Use a finalized v4 manifest. | +| `no output directory exists for run` | The requested run ID has no matching retained output directory. | Verify the ID and retention location; do not create a replacement directory. | +| `artifact is not registered` / `artifact exceeds maximum read size` | Public API artifact read is not manifest-backed or exceeds its bounded read limit. | Read only catalogued artifacts and request an appropriate bounded limit. | +| `package contents do not match ...` / `package integrity verification failed` | ZIP members, checksums, or catalog differ from the finalized manifest. | Treat the package as invalid; preserve it for investigation and rerun packaging from intact evidence. | +| `package metadata ... invalid` / `packaged metadata does not match ...` | Package metadata cannot be decoded or differs from the manifest. | Do not distribute it; investigate tampering or a packaging defect. | +| `requested performance report is missing` / `performance report escapes ...` | A performance-mode run lacks its required report or the path is unsafe. | Preserve the run and investigate runtime output ownership before retrying. | +| `no unique output fingerprint prefix is available` | Existing output paths exhaust all fingerprint prefixes. | Preserve existing evidence, choose a new output root, and investigate collisions. | + +## Maintenance and update errors + +| Error family | Meaning | Correction | +|---------------------------------------------------------------------------------------|----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------| +| `git is unavailable`, `not a repository`, `origin ...`, `remote ...` | Update/development checks cannot establish a valid checkout or reachable remote. | Install Git, use the intended checkout, configure origin, or resolve network access. | +| `--write-manifest requires --next-version` | A persistent integrity-manifest write has no explicit semantic target. | Provide `--next-version X.Y.Z` or use interactive review. | +| `unsupported new-window action` / `new command windows are supported only on Windows` | An update launch action is not allowlisted or host support is absent. | Use `preflight`, `debug`, or `dev` with `--new-window` on Windows. | + +When an error is not represented verbatim above, keep its exact message and the manifest/debug JSON. Host API, optional Windows tool, and collector-specific error text is intentionally preserved as the final detail needed to diagnose that source. diff --git a/docs/FLOW_MATRIX.md b/docs/FLOW_MATRIX.md index f794c481..25c0b69f 100644 --- a/docs/FLOW_MATRIX.md +++ b/docs/FLOW_MATRIX.md @@ -7,7 +7,7 @@ | One collector | `python -m logicytics collector ID --acknowledge-authorization` | | Bounded parallel run | `python -m logicytics run --mode balanced --parallel --acknowledge-authorization` | | Deterministic run | `python -m logicytics run --mode standard --sequential --acknowledge-authorization` | -| Optional extensions | Add `--plugins` or `--mods` after reviewing them | +| Optional extensions | Add `--plugins` after reviewing source and metadata | | Performance measurement | Add `--performance-check` to `run` | | Manifest-only output | Add `--no-package` to `run` or `collector` | | Rerun selected IDs | `run --include ID --rerun-from PATH` | @@ -20,7 +20,6 @@ The executable regression anchors are: - `test_typed_mode_registry_maps_every_user_mode_and_legacy_alias` - `test_explicit_execution_modes_control_isolated_worker_overlap` - `test_performance_report_is_finalized_before_automatic_packaging` -- `test_mods_require_sidecars_and_run_as_isolated_registered_artifacts` - `test_dev_writes_explicit_manifest_and_debug_persists_diagnostics` - `test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window` - `test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in` diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 11d26fc2..7df63c0c 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -70,8 +70,8 @@ python -m logicytics run --mode standard ` ``` Use `--sequential` for a deterministic troubleshooting run, `--no-package` when you need only the manifest-backed run -directory, and `--interactive` when a console window should remain open at the end. Do not add `--plugins` or `--mods` -just to make a command work: those switches opt into user-owned executable code. +directory, and `--interactive` when a console window should remain open at the end. Do not add `--plugins` just to make +a command work: it opts into reviewed user-owned collector code. ## 4. Decide whether the result answers the question diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 2b8c17b3..405c6f5f 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -7,8 +7,8 @@ Older mode flags remain compatibility aliases: `--default` selects `standard`, ` `--minimal` selects `quick`, and `--depth` selects `thorough`. New scripts should use `--mode` or `--profile`. Legacy `CODE/config.ini` is read only as a compatibility input. The explicit `core.integration.legacy_code_outputs` -collector imports approved generated output; the engine does not implicitly scan the repository. `MODS/` is opt-in and -requires sidecars. Run evidence is owned by its manifest-backed run folder. There are no global `ACCESS/`, `RUNS/`, +collector imports approved generated output; the engine does not implicitly scan the repository. Extension support is +limited to reviewed `PluginCollector` modules under `plugins/`. Run evidence is owned by its manifest-backed run folder. There are no global `ACCESS/`, `RUNS/`, `LOGS/`, or `PACKAGES/` stores. Translate supported settings using [Configuration](CONFIGURATION.md), then run `preflight` and `plan` before collecting. diff --git a/docs/MODS.md b/docs/MODS.md deleted file mode 100644 index 51ff8af8..00000000 --- a/docs/MODS.md +++ /dev/null @@ -1,15 +0,0 @@ -# MODS - -This page is the short compatibility entry point for the optional Python-script extension area. -Read [Plugins and extensions](PLUGINS.md) for discovery, sidecars, execution, output registration, and security rules, -then read [Plugin Authoring](PLUGIN_AUTHORING.md) when writing code. - -Enable MODS only deliberately: - -```powershell -python -m logicytics preflight --mods -python -m logicytics run --mode standard --mods --acknowledge-authorization -``` - -Python files require an adjacent metadata sidecar and run in isolated private workspaces. A normal run does not execute -them. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 9a43c15d..6daafe1f 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -89,12 +89,12 @@ publication. ## Extensions and removable media -Plugins and MODS are opt-in because they are user-owned code. Include them only after their source, metadata or sidecar, -and capability declarations have been reviewed: +Plugins are opt-in because they are user-owned collector code. Include them only after their source, metadata, and +capability declarations have been reviewed: ```powershell -python -m logicytics preflight --plugins --mods -python -m logicytics plan --mode standard --plugins --mods +python -m logicytics preflight --plugins +python -m logicytics plan --mode standard --plugins ``` Use `--usb [DRIVE]` only when deliberately targeting a Windows installation on removable storage. The engine rejects diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index f815fc3f..a427f783 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1,36 +1,37 @@ -# Plugins and extensions +# Plugin support -There are two extension routes. A `PluginCollector` is a typed Python collector in `plugins/`; a MOD is a Python script -in `MODS/` with a JSON sidecar. Both are opt-in, preflighted, isolated, quota-bound, and included in manifest-led -packaging when they publish evidence. +Plugins are the only supported extension mechanism. A plugin is a typed Python `PluginCollector` stored below `plugins/`; it is never selected by a normal core-only request. Script sidecars, batch, PowerShell, and executable extension paths are not supported. -## Plugins +## Enable a reviewed plugin -Plugins use the full collector contract and are enabled with `--plugins`. They can be selected by exact ID with -`--include plugin.name`. Read [Plugin Authoring](PLUGIN_AUTHORING.md) for the complete lifecycle. +1. Read its source and declared metadata. +2. Run preflight with plugins enabled. +3. Build a plan and review the exact ID, capabilities, sensitivity, limits, and dependencies. +4. Run only with explicit authorization. -## MODS +```powershell +.\.venv\Scripts\python.exe -m logicytics preflight --plugins +.\.venv\Scripts\python.exe -m logicytics plan --mode standard --plugins +.\.venv\Scripts\python.exe -m logicytics run --mode standard --plugins --acknowledge-authorization +``` -MODS accepts Python `.py` files only. A runnable file requires an adjacent `.py.mod.json` sidecar. Lowercase -snake_case filenames are required; names beginning with `_` are ignored. Normal runs never execute MODS. +For a sensitive plugin, prefer an exact `--include plugin.name` request and document why the capability set is authorized. A blocked capability always overrides a plugin declaration. -Example layout: +## Discovery and identity -```text -MODS/ - inventory/ - local_report.py - local_report.py.mod.json -``` +Plugin modules live under `plugins/`. Each valid module exposes exactly one `PluginCollector` subclass. A plugin ID begins with `plugin.` and must match its file or folder ownership: a normal `plugins/example.py` exposes `plugin.example`; a `main.py` entry point uses its containing folder name. + +Discovery statically validates the source first, then probes metadata in a short-lived restricted process. Invalid unselected plugins are quarantined so an unrelated optional plugin cannot break normal core collection. A selected or globally enabled invalid plugin blocks the request. + +## Contract requirements -Required sidecar fields include `id` (`mod.local_report`), `name`, `version`, `specialty`, `description`, `author`, -`supported_platforms`, `capabilities`, `privilege_level`, `sensitive_data_categories`, `network_access`, -`estimated_cost`, `timeout_seconds`, `maximum_output_bytes`, `maximum_artifact_files`, `output_media_types`, -`minimum_contract_version`, and `default_profiles`. +The complete field-by-field contract is in [Contracts](CONTRACTS.md) and a runnable minimal pattern is in [Plugin Authoring](PLUGIN_AUTHORING.md). In short, a plugin must: -MODS scripts run with `LOGICYTICS_WORKSPACE` as their private working directory. Non-empty files, allowed MIME types, -standard output, and standard error can become artifacts. Lines formatted as `LEVEL: message` become structured events. -A MOD failure affects only that MOD. +- have no import-time collection, network, registry, subprocess, or output side effects; +- construct deterministic metadata and explicitly declare access, privilege, network reach, sensitive categories, limits, output MIME types, and contract version; +- perform bounded work only in its private workspace and temporary directory; +- register complete artifacts through `context.artifacts`, never by writing directly to an output or package path; +- check cancellation during long work and return typed `succeeded`, `partial`, `skipped`, `cancelled`, or `failed` outcomes; and +- keep all host access within declared capabilities and supported platform adapters. -The `subprocess` capability is required for MOD execution; other capabilities must also be declared. A blocked -capability still overrides the declaration. Review third-party scripts before enabling them. +Plugins do not gain authority merely because they are enabled. The runtime applies the same isolated worker, quota, logging, artifact, packaging, cancellation, and capability-policy rules used for core collectors. diff --git a/docs/README.md b/docs/README.md index c1fb805d..10e9b772 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,7 +23,7 @@ with [Evidence Review](EVIDENCE_REVIEW.md). | I need to read JSON, CSV, HTML, or ZIP output | [Results](OUTPUTS.md) and [Formats](FORMATS.md) | | I want to run a plugin | [Plugins](PLUGINS.md) | | I want to write a collector | [Plugin Authoring](PLUGIN_AUTHORING.md), [Contracts](CONTRACTS.md) | -| Something went wrong | [Troubleshooting](TROUBLESHOOTING.md) | +| Something went wrong | [Troubleshooting](TROUBLESHOOTING.md), [Error Reference](ERRORS.md) | | I am maintaining the repository | [Development](DEVELOPMENT.md), [Verification](VERIFICATION.md) | ## Safety rule of thumb diff --git a/docs/SAFETY.md b/docs/SAFETY.md index f8f7377c..6e790fbc 100644 --- a/docs/SAFETY.md +++ b/docs/SAFETY.md @@ -22,9 +22,8 @@ event channel, a cancellation boundary, and declared resource limits. One failur does not normally stop independent collectors. The supervisor terminates only the worker process tree it owns when a timeout or memory limit is exceeded. -Core collectors are discovered from `core/` and selected by profiles. Plugin collectors in `plugins/` and Python MODS in -`MODS/` never run unless explicitly enabled. A plugin or MOD must pass static and runtime preflight before it can enter -a plan. +Core collectors are discovered from `core/` and selected by profiles. Plugin collectors in `plugins/` never run unless +explicitly enabled. A plugin must pass static and runtime preflight before it can enter a plan. ## Capability gates @@ -46,6 +45,6 @@ package, hash, and logs are no longer needed. - `--reboot` and `--shutdown` are mutually exclusive and are scheduled only after durable package publication. - `--performance-check` forces serial measurement and is intended for measurement, not fastest collection. -- `--plugins` and `--mods` execute additional user-owned code. +- `--plugins` executes additional user-owned collector code. - `--usb` changes the source Windows installation and rejects unsafe output/cache/temp placement on that disk. - `--update --apply` performs `git pull`; use it only when repository state and remote are understood. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 202d4ab4..0e8da987 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -36,8 +36,8 @@ First confirm the exact dotted ID in [Core Collector Catalog](CORE_COLLECTORS.md blocks as the intended run. A collector can be excluded by a selector, not belong to the mode, be invalid in preflight, or require a capability you blocked. -Plugins require `--plugins`; MODS require `--mods`. Their absence from a normal core-only plan is intentional. Review -their sidecars and source before enabling them. +Plugins require `--plugins`. Their absence from a normal core-only plan is intentional. Review their source and metadata +before enabling them. ## A collector is skipped, partial, failed, or canceled diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index bf829890..45e0374e 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -149,8 +149,6 @@ def request( parallel = not performance_check and ( explicit_parallel or (mode is not None and mode.strategy is ExecutionStrategy.PARALLEL)) - enable_mods = getattr(arguments, "mods", False) or (mode.enable_mods if mode is not None else False) - requested_workers = getattr(arguments, "workers", None) if sequential and requested_workers is not None and requested_workers != 1: @@ -224,10 +222,8 @@ def request( include_arguments = tuple(getattr(arguments, "include", ()) or ()) exclude_arguments = tuple(getattr(arguments, "exclude", ()) or ()) plugins_enabled = bool(getattr(arguments, "plugins", False)) - mods_enabled = bool(getattr(arguments, "mods", False)) - if selection_only and ( - include_arguments or exclude_arguments or arguments.profile is not None or plugins_enabled or mods_enabled): + include_arguments or exclude_arguments or arguments.profile is not None or plugins_enabled): raise ValueError("collector execution cannot combine its ID with profile or selection flags") includes = (arguments.collector_id,) if selection_only else include_arguments @@ -242,7 +238,6 @@ def request( exclude=exclude_arguments, selection_only=selection_only, enable_plugins=plugins_enabled, - enable_mods=enable_mods, max_workers=worker_count, acknowledge_authorization=getattr( arguments, @@ -338,11 +333,6 @@ def parser() -> argparse.ArgumentParser: action="store_true", help="Enable all valid opt-in plugin collectors for the selected profile.", ) - subparser.add_argument( - "--mods", - action="store_true", - help="Enable valid sidecar-declared scripts from the MODS directory.", - ) subparser.add_argument( "--workers", type=int, @@ -1044,7 +1034,6 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str validation = report.to_dict( selected_plugins=tuple(arguments.include), enable_plugins=arguments.plugins, - enable_mods=arguments.mods, ) sysinternals = ensure_sysinternals(root, configuration.maintenance).to_dict() @@ -1277,7 +1266,6 @@ def report_preflight_progress(phase: str, checked: int, total: int, current: str exclude_count=len(request.exclude), blocked_capabilities=len(request.blocked_capabilities), enable_plugins=request.enable_plugins, - enable_mods=request.enable_mods, max_workers=request.max_workers, ) plan = build_plan( diff --git a/logicytics/contracts.py b/logicytics/contracts.py index d0a7814c..15b51563 100644 --- a/logicytics/contracts.py +++ b/logicytics/contracts.py @@ -14,7 +14,7 @@ CONTRACT_VERSION = "4.0" _CUSTOM_SPECIALTY = re.compile(r"^[a-z][a-z0-9_]{1,63}$") -_COLLECTOR_ID = re.compile(r"^(?:core|plugin|mod)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?$") +_COLLECTOR_ID = re.compile(r"^(?:core|plugin)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?$") _SEMANTIC_VERSION = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$") _CONTRACT_VERSION = re.compile(r"^\d+\.\d+$") _LABEL = re.compile(r"^[a-z][a-z0-9_]{0,63}$") @@ -29,7 +29,6 @@ class CollectorKind(StrEnum): CORE = "core" PLUGIN = "plugin" - MOD = "mod" class CollectorStatus(StrEnum): @@ -506,7 +505,6 @@ class RunRequest: exclude: tuple[str, ...] = () selection_only: bool = False enable_plugins: bool = False - enable_mods: bool = False max_workers: int = 4 acknowledge_authorization: bool = False blocked_capabilities: tuple[Capability, ...] = () @@ -541,7 +539,6 @@ def __post_init__(self) -> None: for name in ( "selection_only", "enable_plugins", - "enable_mods", "acknowledge_authorization", "performance_check", ): diff --git a/logicytics/module/api.py b/logicytics/module/api.py index d21326c8..345e2141 100644 --- a/logicytics/module/api.py +++ b/logicytics/module/api.py @@ -24,7 +24,7 @@ _RUN_ID = re.compile(r"run-[0-9a-f]{32}") _ARTIFACT_ID = re.compile(r"artifact\.[0-9a-f]{32}") -_COLLECTOR_ID = re.compile(r"(?:core|plugin|mod)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?") +_COLLECTOR_ID = re.compile(r"(?:core|plugin)\.[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)?") _MAXIMUM_ARTIFACT_READ_BYTES = 64 * 1024 * 1024 _DEFAULT_ARTIFACT_READ_BYTES = 16 * 1024 * 1024 diff --git a/logicytics/module/configuration.py b/logicytics/module/configuration.py index c59493bc..9976a67f 100644 --- a/logicytics/module/configuration.py +++ b/logicytics/module/configuration.py @@ -20,7 +20,7 @@ MAXIMUM_CONFIGURATION_BYTES = 2 * 1024 * 1024 DEFAULT_MAXIMUM_RUN_OUTPUT_BYTES = 4 * 1024 * 1024 * 1024 MAXIMUM_RUN_OUTPUT_BYTES = 64 * 1024 * 1024 * 1024 -_COLLECTOR_ID = re.compile(r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|(?:plugin|mod)\.[a-z][a-z0-9_]*)$") +_COLLECTOR_ID = re.compile(r"^(?:core\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*|plugin\.[a-z][a-z0-9_]*)$") _SETTING_NAME = re.compile(r"^[a-z][a-z0-9_]*$") _ROOT_FIELDS = frozenset( { diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index 5376b777..f522699c 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -24,9 +24,6 @@ from logicytics.platform_adapters import process_adapter _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") -_MOD_FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") -_MOD_EXTENSIONS = {".py": "mod_python"} -_REMOVED_MOD_EXTENSIONS = frozenset({".ps1", ".bat", ".exe"}) _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} _APPLICATION_IMPORTS = { "CollectorSnapshot", @@ -131,7 +128,6 @@ def to_dict( *, selected_plugins: tuple[str, ...] = (), enable_plugins: bool = False, - enable_mods: bool = False, ) -> dict[str, list[dict[str, object]]]: """Classify invalid plugins as quarantined unless the request selects them.""" valid = [ @@ -152,7 +148,6 @@ def to_dict( if ( candidate.kind is CollectorKind.CORE or (enable_plugins and candidate.kind is CollectorKind.PLUGIN) - or (enable_mods and candidate.kind is CollectorKind.MOD) or candidate.selection_id in selected ): invalid.append(item) @@ -406,72 +401,6 @@ def _validate_static(path: Path, kind: CollectorKind) -> CollectorCandidate: return candidate -def _validate_mod(path: Path, mods_root: Path) -> CollectorCandidate: - """Validate one legacy script mod and its mandatory immutable metadata sidecar.""" - candidate = CollectorCandidate( - path=path, - kind=CollectorKind.MOD, - expected_class="", - execution_type=_MOD_EXTENSIONS.get(path.suffix.casefold(), "unsupported"), - ) - if not _is_within(path, mods_root) or path.is_symlink(): - candidate.static_errors.append("mod path must remain a regular file inside MODS") - return candidate - if path.suffix.casefold() in _REMOVED_MOD_EXTENSIONS: - candidate.static_errors.append("MODS supports Python (.py) scripts only") - return candidate - if not _MOD_FILENAME.fullmatch(path.name): - candidate.static_errors.append("mod filename must be lowercase snake_case with a supported extension") - sidecar = path.with_suffix(path.suffix + ".mod.json") - if not sidecar.is_file() or sidecar.is_symlink() or not _is_within(sidecar, mods_root): - candidate.static_errors.append(f"mod requires metadata sidecar {sidecar.name}") - return candidate - try: - raw = json.loads(sidecar.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - candidate.static_errors.append(f"cannot parse mod metadata sidecar: {error}") - return candidate - if not isinstance(raw, dict): - candidate.static_errors.append("mod metadata sidecar must contain an object") - return candidate - required = { - "id", - "name", - "version", - "specialty", - "description", - "author", - "supported_platforms", - "capabilities", - "privilege_level", - "sensitive_data_categories", - "network_access", - "estimated_cost", - "timeout_seconds", - "maximum_output_bytes", - "maximum_artifact_files", - "output_media_types", - "minimum_contract_version", - "default_profiles", - } - missing = sorted(required - set(raw)) - if missing: - candidate.static_errors.append(f"mod metadata must explicitly declare: {', '.join(missing)}") - return candidate - try: - metadata = CollectorMetadata.from_dict(raw, allow_custom_specialty=True) - except (KeyError, TypeError, ValueError) as error: - candidate.runtime_error = f"invalid mod metadata: {error}" - return candidate - expected_id = f"mod.{path.stem}" - if metadata.id != expected_id: - candidate.runtime_error = f"mod ID must match its filename: {expected_id}" - elif Capability.SUBPROCESS not in metadata.capabilities: - candidate.runtime_error = "legacy script mods must declare the subprocess capability" - else: - candidate.metadata = metadata - return candidate - def _validate_class_shape( class_node: ast.ClassDef, @@ -689,14 +618,6 @@ def discover(project_root: Path) -> tuple[CollectorCandidate, ...]: _iter_candidates(project_root, CollectorKind.CORE)] candidates.extend( _validate_static(path, CollectorKind.PLUGIN) for path in _iter_candidates(project_root, CollectorKind.PLUGIN)) - mods_root = project_root / "MODS" - if mods_root.exists(): - for path in sorted(item for item in mods_root.rglob("*") if item.is_file()): - relative = path.relative_to(mods_root) - if any(part.startswith("_") for part in relative.parts): - continue - if path.suffix.casefold() in _MOD_EXTENSIONS or path.suffix.casefold() in _REMOVED_MOD_EXTENSIONS: - candidates.append(_validate_mod(path, mods_root)) return tuple(candidates) @@ -730,8 +651,6 @@ def _accept_runtime_metadata(candidate: CollectorCandidate, payload: object) -> f"plugin.{candidate.path.parent.name if candidate.path.name == 'main.py' else candidate.path.stem}" ): candidate.runtime_error = "plugin collector ID must match its plugin folder or filename" - elif candidate.kind is CollectorKind.MOD and metadata.id != f"mod.{candidate.path.stem}": - candidate.runtime_error = "mod collector ID must match its script filename" else: candidate.metadata = metadata @@ -879,10 +798,6 @@ def preflight( if progress is not None: progress("checked", index, total, candidate.selection_id) continue - if candidate.kind is CollectorKind.MOD: - if progress is not None: - progress("checked", index, total, candidate.selection_id) - continue relative_path = candidate.path.resolve().relative_to(project_root.resolve()).as_posix() entry = cached.get(relative_path) if ( diff --git a/logicytics/module/interaction.py b/logicytics/module/interaction.py index 7ae475cb..b9ef479f 100644 --- a/logicytics/module/interaction.py +++ b/logicytics/module/interaction.py @@ -19,7 +19,6 @@ "threaded": "standard collection with bounded parallel workers", "minimal": "quick basic essential collection", "depth": "deep exhaustive slow collection", - "modded": "normal collection plus opt in MODS extensions", "performance-check": "sequential collector duration performance analysis", "usage": "interaction statistics and flag usage graph", "debug": "diagnostic environment configuration and integrity checks", diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index b69847f7..8115a0c1 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -97,10 +97,6 @@ def collector_log_source(collector_id: str | None) -> str | None: return collector_id if parts[0] == "core" and len(parts) >= 3: return f"core.{parts[1]}.{_script_initials(parts[-1])}" - if parts[0] == "mod": - prefix = ".".join(parts[1:-1]) - suffix = _script_initials(parts[-1]) - return f"mods.{prefix + '.' if prefix else ''}{suffix}" if parts[0] == "plugin": return f"plugins.{'.'.join(parts[1:])}" return collector_id diff --git a/logicytics/module/mod_runner.py b/logicytics/module/mod_runner.py deleted file mode 100644 index f484583b..00000000 --- a/logicytics/module/mod_runner.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Audit-confined bootstrap for legacy Python MODS scripts.""" - -from __future__ import annotations - -import json -import runpy -import sys -from pathlib import Path - - -def main(argv: list[str] | None = None) -> int: - """Run one copied Python MOD with workspace mutation and capability enforcement.""" - arguments = sys.argv[1:] if argv is None else argv - engine_root = Path(__file__).resolve().parents[2] - sys.path.insert(0, str(engine_root)) - from logicytics.module.logging import ApplicationLogger - - if len(arguments) != 5: - ApplicationLogger.render_section( - sys.stderr, - "Python MOD runner error", - ("The runner requires script, workspace, collector ID, capabilities, and blocked capabilities.",), - ) - return 2 - script = Path(arguments[0]).resolve() - workspace = Path(arguments[1]).resolve() - collector_id = arguments[2] - try: - capability_values = json.loads(arguments[3]) - if not isinstance(capability_values, list): - raise ValueError("capabilities must be a JSON list") - blocked_capability_values = json.loads(arguments[4]) - if not isinstance(blocked_capability_values, list): - raise ValueError("blocked capabilities must be a JSON list") - except (json.JSONDecodeError, ValueError) as error: - ApplicationLogger.render_section( - sys.stderr, - "Python MOD runner error", - (f"Invalid capabilities: {error}",), - ) - return 2 - - from logicytics.contracts import Capability - from logicytics.module.runtime import _WorkerMutationGuard - - try: - capabilities = tuple(Capability(value) for value in capability_values) - blocked_capabilities = tuple(Capability(value) for value in blocked_capability_values) - except ValueError as error: - ApplicationLogger.render_section( - sys.stderr, - "Python MOD runner error", - (f"Invalid capability: {error}",), - ) - return 2 - try: - script.relative_to(workspace) - except ValueError: - ApplicationLogger.render_section( - sys.stderr, - "Python MOD runner error", - ("The script must be copied inside its private workspace.",), - ) - return 2 - - guard = _WorkerMutationGuard( - workspace, - workspace / "published", - collector_id, - capabilities, - script, - blocked_capabilities, - ) - guard.active = True - previous_argv = sys.argv - try: - sys.argv = [str(script)] - runpy.run_path(str(script), run_name="__main__") - finally: - guard.active = False - sys.argv = previous_argv - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/logicytics/module/modes.py b/logicytics/module/modes.py index ab67dd06..dc629e53 100644 --- a/logicytics/module/modes.py +++ b/logicytics/module/modes.py @@ -52,13 +52,12 @@ class ExecutionStrategy(str, Enum): @dataclass(frozen=True, slots=True) class ExecutionMode: - """Complete profile, extension, and scheduling behavior for one named mode.""" + """Complete profile and scheduling behavior for one named mode.""" name: str description: str profile: str strategy: ExecutionStrategy = ExecutionStrategy.CONFIGURED - enable_mods: bool = False _MODE_LIST = ( @@ -116,9 +115,7 @@ def _candidate_modes(candidate: CollectorCandidate) -> tuple[str, ...]: metadata = candidate.metadata selected: list[str] = [] for mode in EXECUTION_MODES.values(): - if candidate.kind is CollectorKind.MOD: - enabled = mode.enable_mods - elif candidate.kind is CollectorKind.PLUGIN: + if candidate.kind is CollectorKind.PLUGIN: enabled = False else: enabled = mode.profile in metadata.default_profiles diff --git a/logicytics/module/output_layout.py b/logicytics/module/output_layout.py index 6dd1ad0e..c606dfb8 100644 --- a/logicytics/module/output_layout.py +++ b/logicytics/module/output_layout.py @@ -46,9 +46,7 @@ def allocate_output_run_directory(layout: OutputLayout, run_id: str) -> Path: occupied = ( layout.runs / token, layout.packages / f"{token}.zip", - layout.packages / f"mods-{token}.zip", layout.hashes / f"{token}.zip.sha256", - layout.hashes / f"mods-{token}.zip.sha256", layout.performance_logs / f"{token}.log", ) if not any(path.exists() for path in occupied): diff --git a/logicytics/module/packaging.py b/logicytics/module/packaging.py index 27e398d5..2a9ff629 100644 --- a/logicytics/module/packaging.py +++ b/logicytics/module/packaging.py @@ -26,10 +26,10 @@ def _sidecar_filename(package_name: str) -> str: """Return a concise sidecar name from the package's storage fingerprint.""" - match = re.fullmatch(r"(?Pmods-)?(?P[0-9a-f]{8,64})\.zip", package_name) + match = re.fullmatch(r"(?P[0-9a-f]{8,64})\.zip", package_name) if match is None: raise ValueError("package name must contain a lowercase SHA-256 storage fingerprint") - return f"{match['prefix'] or ''}{match['fingerprint']}.zip.sha256" + return f"{match['fingerprint']}.zip.sha256" def _validate_sidecar_slot(hash_path: Path, package_name: str) -> None: @@ -244,89 +244,6 @@ def _verify_archive(package_path: Path, manifest: RunManifest, expected_names: s raise ValueError(f"packaged artifact verification failed: {artifact.relative_path}") -def _package_mod_artifacts( - package_directory: Path, - hash_directory: Path, - manifest: RunManifest, - artifact_sources: list[tuple[Path, str]], - package_name: str, -) -> dict[str, str]: - """Publish MODS evidence in a separately named atomic package and sidecar.""" - mod_sources = [ - (source, archive_name) - for source, archive_name in artifact_sources - if any( - artifact.collector_id.startswith("mod.") and _artifact_archive_name(artifact) == archive_name - for artifact in manifest.artifact_list() - ) - ] - if not mod_sources: - return {} - package_path = package_directory / f"mods-{package_name}" - temporary_package = package_path.with_suffix(".zip.tmp") - temporary_hash = hash_directory / f".{package_path.name}.sha256.tmp" - mod_catalog = { - "run_id": manifest.run_id, - "artifacts": [item for item in manifest.artifact_catalog() if - str(item.get("collector_id", "")).startswith("mod.")], - } - expected_names = {"metadata/mods.json", *(archive_name for _, archive_name in mod_sources)} - try: - with zipfile.ZipFile(temporary_package, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.writestr("metadata/mods.json", json.dumps(mod_catalog, indent=2, sort_keys=True) + "\n") - for source, archive_name in mod_sources: - _stream_archive_member(archive, source, archive_name) - with zipfile.ZipFile(temporary_package) as archive: - if set(archive.namelist()) != expected_names or archive.testzip() is not None: - raise ValueError("MODS package contents failed verification") - for artifact in manifest.artifact_list(): - if not artifact.collector_id.startswith("mod."): - continue - member = archive.getinfo(_artifact_archive_name(artifact)) - with archive.open(member) as stream: - digest = _sha256_stream(stream) - if member.file_size != artifact.size_bytes or digest != artifact.sha256: - raise ValueError(f"MODS artifact verification failed: {artifact.relative_path}") - - digest = sha256_file(temporary_package) - hash_path = hash_directory / _sidecar_filename(package_path.name) - _validate_sidecar_slot(hash_path, package_path.name) - temporary_hash.write_text(f"{digest} {package_path.name}\n", encoding="ascii") - package_backup = package_path.with_suffix(".zip.backup") - hash_backup = hash_path.with_suffix(".sha256.backup") - try: - if package_path.exists(): - os.replace(package_path, package_backup) - if hash_path.exists(): - os.replace(hash_path, hash_backup) - os.replace(temporary_package, package_path) - os.replace(temporary_hash, hash_path) - except BaseException: - if package_path.exists(): - package_path.unlink() - if hash_path.exists() and hash_backup.exists(): - hash_path.unlink() - if package_backup.exists(): - os.replace(package_backup, package_path) - if hash_backup.exists(): - os.replace(hash_backup, hash_path) - raise - if package_backup.exists(): - package_backup.unlink() - if hash_backup.exists(): - hash_backup.unlink() - finally: - if temporary_package.exists(): - temporary_package.unlink() - if temporary_hash.exists(): - temporary_hash.unlink() - return { - "mods_path": str(package_path), - "mods_sha256_path": str(hash_path), - "mods_sha256": digest, - } - - def package_manifest( run_directory: Path, manifest: RunManifest, @@ -348,8 +265,7 @@ def package_manifest( artifact_hash_path = hash_directory / "artifacts.sha256" artifact_sources = _artifact_sources(run_directory, manifest) log_sources = _log_sources(run_directory, manifest) - mods_package = _package_mod_artifacts(package_directory, hash_directory, manifest, artifact_sources, package_name) - manifest.package = {"path": str(package_path), **mods_package} + manifest.package = {"path": str(package_path)} write_manifest(manifest_path, manifest) _write_text_atomic(summary_path, _summary(manifest), encoding="utf-8") _write_text_atomic(artifact_hash_path, _artifact_checksum_catalog(manifest), encoding="ascii") @@ -402,7 +318,6 @@ def package_manifest( "path": str(package_path), "sha256_path": str(hash_path), "sha256": package_sha256, - **mods_package, } write_manifest(manifest_path, manifest) return package_path, hash_path diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index 8f1bf8d2..92ee33b0 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -57,8 +57,6 @@ def _selected_by_request(candidate: CollectorCandidate, request: RunRequest) -> return False if candidate.kind is CollectorKind.PLUGIN and not request.enable_plugins: return False - if candidate.kind is CollectorKind.MOD: - return request.enable_mods return request.profile in metadata.default_profiles @@ -105,7 +103,6 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: candidate.kind is CollectorKind.CORE or candidate.selection_id in request.include or (request.enable_plugins and candidate.kind is CollectorKind.PLUGIN) - or (request.enable_mods and candidate.kind is CollectorKind.MOD) ) ] if invalid_selected: @@ -134,8 +131,6 @@ def build_plan(report: PreflightReport, request: RunRequest) -> RunPlan: if dependency.kind is CollectorKind.PLUGIN and not ( request.enable_plugins or dependency_id in request.include): raise PlanError(f"plugin dependency {dependency_id} must be explicitly selected or plugins enabled") - if dependency.kind is CollectorKind.MOD and not (request.enable_mods or dependency_id in request.include): - raise PlanError(f"mod dependency {dependency_id} must be explicitly selected or mods enabled") if dependency.metadata.sensitive_data_categories and dependency_id not in request.include: raise PlanError(f"sensitive dependency {dependency_id} must be explicitly selected") if dependency_id not in selected: diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index 1dccc4f2..c0762c80 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -5,7 +5,6 @@ import contextlib import importlib.util import json -import mimetypes import multiprocessing import os import queue @@ -42,7 +41,6 @@ ValidationResult, ) from logicytics.module.artifacts import WorkspaceArtifactWriter -from logicytics.module.command_runner import parse_level_messages from logicytics.module.configuration import AppConfig from logicytics.module.discovery import CollectorCandidate from logicytics.module.errors import CapabilityPolicyError, LogicyticsError @@ -443,169 +441,6 @@ def _result_from_dict(data: _SerializedResult) -> CollectorResult: ) -def _mod_command( - script: Path, - execution_type: str, - workspace: Path | None = None, - collector_id: str = "mod.legacy", - capabilities: tuple[Capability, ...] = (), - blocked_capabilities: tuple[Capability, ...] = (), -) -> list[str]: - """Build a shell-free command for one copied legacy MODS script.""" - if execution_type != "mod_python": - raise ValueError("MODS supports Python (.py) scripts only") - if workspace is None: - raise ValueError("Python mod execution requires a private workspace") - runner = Path(__file__).with_name("mod_runner.py") - return [ - sys.executable, - "-I", - str(runner), - str(script), - str(workspace), - collector_id, - json.dumps([capability.value for capability in capabilities]), - json.dumps([capability.value for capability in blocked_capabilities]), - ] - - -def _run_mod_worker(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) -> None: - """Adapt a sidecar-declared legacy script to the isolated collector contract.""" - metadata = CollectorMetadata.from_dict(payload["metadata"], allow_custom_specialty=True) - workspace = Path(payload["workspace"]) - artifact_root = Path(payload["artifact_root"]) - cancellation_file = Path(payload["cancellation_file"]) - source_directory = workspace / "source" - source_directory.mkdir(parents=True, exist_ok=True) - source = Path(payload["path"]) - copied_script = source_directory / source.name - shutil.copy2(source, copied_script) - writer = WorkspaceArtifactWriter( - metadata.id, - workspace, - artifact_root, - metadata.maximum_output_bytes, - metadata.maximum_artifact_files, - source_category=str(metadata.specialty), - maximum_artifact_bytes=metadata.maximum_artifact_bytes, - run_output_budget_bytes=payload["run_output_budget_bytes"], - cancellation_file=cancellation_file, - ) - logger = get_event_logger( - workspace / "events.jsonl", - run_id=payload["run_id"], - collector_id=metadata.id, - ) - stdout_path = workspace / "script_stdout.txt" - stderr_path = workspace / "script_stderr.txt" - guard = _WorkerMutationGuard( - workspace, - artifact_root, - metadata.id, - metadata.capabilities, - source, - payload["blocked_capabilities"], - ) - result: CollectorResult - try: - if cancellation_file.exists(): - result = CollectorResult.cancelled("mod cancelled before execution") - else: - command = _mod_command( - copied_script, - payload["execution_type"], - workspace, - metadata.id, - metadata.capabilities, - payload["blocked_capabilities"], - ) - environment = { - "PATH": os.environ.get("PATH", ""), - "SYSTEMROOT": os.environ.get("SYSTEMROOT", ""), - "WINDIR": os.environ.get("WINDIR", ""), - "COMSPEC": os.environ.get("COMSPEC", ""), - "TEMP": payload["temporary_directory"], - "TMP": payload["temporary_directory"], - "LOGICYTICS_RUN_ID": payload["run_id"], - "LOGICYTICS_COLLECTOR_ID": metadata.id, - "LOGICYTICS_WORKSPACE": str(workspace), - } - guard.active = True - try: - completed = process_adapter.run( - command, - cwd=workspace, - env=environment, - capture_directory=workspace / "tmp", - capture_output=True, - check=False, - text=True, - ) - finally: - guard.active = False - stdout_path.write_text(completed.stdout, encoding="utf-8") - stderr_path.write_text(completed.stderr, encoding="utf-8") - for level, message in parse_level_messages(completed.stdout): - logger.event(level.casefold(), message) - excluded_roots = {source_directory.resolve(), (workspace / "tmp").resolve()} - excluded_files = { - stdout_path.resolve(), - stderr_path.resolve(), - (workspace / "events.jsonl").resolve(), - } - candidates = [stdout_path, stderr_path] - candidates.extend( - path - for path in sorted(workspace.rglob("*")) - if path.is_file() - and path.resolve() not in excluded_files - and not any(path.resolve().is_relative_to(root) for root in excluded_roots) - ) - artifacts: list[Artifact] = [] - for path in candidates: - if not path.exists() or path.stat().st_size == 0: - continue - media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" - if media_type not in metadata.output_media_types: - raise ValueError(f"mod output media type was not declared: {media_type} ({path.name})") - artifacts.append(writer.register_file(path, media_type=media_type)) - if cancellation_file.exists(): - result = CollectorResult.cancelled("mod cancelled after execution", tuple(artifacts)) - elif completed.returncode == 0: - result = CollectorResult.succeeded("legacy mod completed", tuple(artifacts)) - else: - result = CollectorResult.failed( - "legacy mod exited unsuccessfully", - errors=( - f"exit code {completed.returncode}", - completed.stderr.strip() or "no stderr", - ), - artifacts=tuple(artifacts), - ) - except CapabilityPolicyError as error: - guard.active = False - logger.event( - "error", - "capability_policy_violation", - code=error.code, - capability=error.capability, - operation=error.operation, - ) - result = CollectorResult.failed( - "legacy mod capability policy violation", - errors=(str(error),), - artifacts=writer.artifacts, - ) - except BaseException as error: - guard.active = False - result = CollectorResult.failed( - "legacy mod worker crashed", - errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), - artifacts=writer.artifacts, - ) - result_queue.put({"collector_id": metadata.id, "result": _serialize_result(result)}) - - def _configure_worker_temporary_directory(directory: Path) -> Callable[[], None]: """Route stdlib and child-process temporary files into one worker workspace.""" location = str(directory.resolve()) @@ -637,23 +472,8 @@ def _worker_entry(payload: _WorkerPayload, result_queue: Queue[_WorkerMessage]) temporary_directory.mkdir(parents=True, exist_ok=True) restore_temporary_directory = _configure_worker_temporary_directory(temporary_directory) if payload["execution_type"] != "collector": - try: - _run_mod_worker(payload, result_queue) - except BaseException as error: - result_queue.put( - { - "collector_id": payload["collector_id"], - "result": _serialize_result( - CollectorResult.failed( - "legacy mod worker crashed", - errors=(f"{type(error).__name__}: {error}", traceback.format_exc()), - ) - ), - } - ) - finally: - restore_temporary_directory() - return + restore_temporary_directory() + raise ValueError("unsupported collector execution type") stdout_path = workspace / "stdout.log" stderr_path = workspace / "stderr.log" try: diff --git a/tests/fixtures/collectors.py b/tests/fixtures/collectors.py index 8295863b..669250e5 100644 --- a/tests/fixtures/collectors.py +++ b/tests/fixtures/collectors.py @@ -66,33 +66,6 @@ def plugin_collector_source() -> str: ) -def mod_metadata(name: str, *, filesystem_write: bool = False) -> dict[str, Any]: - """Return a complete sidecar declaration for a harmless legacy script fixture.""" - return { - "id": f"mod.{name}", - "name": f"{name} mod", - "version": "1.0.0", - "specialty": "integration", - "description": "Harmless isolated legacy script fixture.", - "author": "tests", - "supported_platforms": [sys.platform], - "capabilities": [ - "subprocess", - *(["filesystem_write"] if filesystem_write else []), - ], - "privilege_level": "standard", - "sensitive_data_categories": [], - "network_access": "none", - "estimated_cost": "low", - "timeout_seconds": 15, - "maximum_output_bytes": 1024 * 1024, - "maximum_artifact_files": 10, - "output_media_types": ["text/plain"], - "minimum_contract_version": "4.0", - "default_profiles": ["standard"], - } - - def delayed_collector_source( filename: str, delay: float, diff --git a/tests/test_cli.py b/tests/test_cli.py index bf22a8bc..5bc154b8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -72,6 +72,11 @@ def test_run_parser_accepts_performance_check(self) -> None: self.assertEqual("deep", request.profile) self.assertEqual(1, request.max_workers) + def test_run_parser_rejects_removed_script_extension_flag(self) -> None: + """Only typed plugin collectors remain an opt-in extension surface.""" + with self.assertRaises(SystemExit): + cli_methods.parser().parse_args(["run", "--mods"]) + def test_config_flag_is_accepted_before_or_after_a_subcommand(self) -> None: """Configuration selection must not depend on flag placement around the action.""" parser = cli_methods.parser() @@ -151,14 +156,14 @@ def test_configuration_capabilities_are_combined_with_cli_blocks(self) -> None: ) def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None: - """One immutable matrix owns profile, scheduling, and MODS behavior.""" + """One immutable matrix owns profile and scheduling behavior.""" parser = cli_methods.parser() expected = { - "standard": ("standard", 1, False), - "balanced": ("standard", 4, False), - "quick": ("minimal", 4, False), - "thorough": ("deep", 4, False), - "offline": ("offline", 4, False), + "standard": ("standard", 1), + "balanced": ("standard", 4), + "quick": ("minimal", 4), + "thorough": ("deep", 4), + "offline": ("offline", 4), } self.assertEqual(set(expected), set(EXECUTION_MODES)) @@ -183,7 +188,6 @@ def test_typed_mode_registry_maps_every_user_mode_and_legacy_alias(self) -> None ( run_request.profile, run_request.max_workers, - run_request.enable_mods, ), ) diff --git a/tests/test_contracts.py b/tests/test_contracts.py index c240de0c..714f69e4 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -11,6 +11,7 @@ CollectorStatus, EvidenceKind, ResourceClass, + RunRequest, Specialty, ) @@ -45,7 +46,8 @@ def test_collector_metadata_rejects_invalid_identity_and_limits(self) -> None: specialty=Specialty.SYSTEM, description="Example collector.", author="tests", - ) + ) + with self.assertRaisesRegex(ValueError, "id has an invalid schema"): CollectorMetadata(**{**common, "id": "example"}) with self.assertRaisesRegex(ValueError, "semantic versioning"): @@ -170,6 +172,11 @@ def test_artifact_contract_rejects_malformed_catalog_metadata(self) -> None: with self.assertRaisesRegex(ValueError, message): Artifact(**{**valid, **changes}) + def test_requests_reject_removed_script_extension_identifiers(self) -> None: + """Only core and plugin collector IDs belong to the public contract.""" + with self.assertRaisesRegex(ValueError, "collector IDs"): + RunRequest(include=("mod.legacy",)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_flow_matrix.py b/tests/test_flow_matrix.py index dc35ccdc..3e99a4f5 100644 --- a/tests/test_flow_matrix.py +++ b/tests/test_flow_matrix.py @@ -25,7 +25,6 @@ def test_every_required_flow_has_executable_regression_evidence(self) -> None: "minimal": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", "deep": "test_typed_mode_registry_maps_every_user_mode_and_legacy_alias", "performance check": "test_performance_report_is_finalized_before_automatic_packaging", - "mods": "test_mods_require_sidecars_and_run_as_isolated_registered_artifacts", "debug": "test_dev_writes_explicit_manifest_and_debug_persists_diagnostics", "update": "test_update_can_explicitly_launch_an_allowlisted_action_in_a_new_window", "usage": "test_semantic_flag_matching_history_usage_and_graph_are_local_and_opt_in", diff --git a/tests/test_logging.py b/tests/test_logging.py index 57cbefa5..df9b2dba 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -39,10 +39,9 @@ class LoggingTests(unittest.TestCase): """Logging, output layout, file listing, and command-runner behavior.""" - def test_collector_sources_are_specific_and_core_mod_scripts_are_abbreviated(self) -> None: + def test_collector_sources_are_specific_and_scripts_are_abbreviated(self) -> None: """Collector log sources retain their owner while shortening noisy script names.""" self.assertEqual("core.browser.bdb", collector_log_source("core.browser.browser_data_backup")) - self.assertEqual("mods.fb", collector_log_source("mod.foo_bar")) self.assertEqual("plugins.folder.abc", collector_log_source("plugin.folder.abc")) def test_debug_file_logs_keep_full_source_and_callsite_line(self) -> None: diff --git a/tests/test_mods.py b/tests/test_mods.py deleted file mode 100644 index 6a9af0c4..00000000 --- a/tests/test_mods.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Regression coverage for optional mod discovery and execution.""" - -from __future__ import annotations - -import json -import tempfile -import unittest -import zipfile -from pathlib import Path - -from logicytics.cli import cli_methods -from logicytics.contracts import Capability, RunRequest -from logicytics.module.configuration import default_config -from logicytics.module.discovery import preflight -from logicytics.module.errors import PreflightError -from logicytics.module.planner import build_plan -from logicytics.module.runtime import RunSupervisor -from tests.fixtures.collectors import mod_metadata - - -class ModTests(unittest.TestCase): - """Legacy MOD discovery, validation, security, and execution behavior.""" - - def test_mods_require_sidecars_and_run_as_isolated_registered_artifacts(self) -> None: - """Legacy scripts enter the pipeline only through typed metadata and worker isolation.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - - script = mods / "example.py" - script.write_text( - "from pathlib import Path\n" - "Path('report.txt').write_text('mod evidence\\n', encoding='utf-8')\n" - "print('INFO: fixture completed')\n", - encoding="utf-8", - ) - - report = preflight(root) - self.assertEqual(1, len(report.invalid)) - self.assertIn( - "requires metadata sidecar", - report.invalid[0].static_errors[0], - ) - - with self.assertRaises(PreflightError): - build_plan(report, RunRequest(enable_mods=True)) - - script.with_suffix(".py.mod.json").write_text( - json.dumps(mod_metadata("example")), - encoding="utf-8", - ) - - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - self.assertEqual( - (), - build_plan(report, RunRequest()).collectors, - ) - - plan = build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=(Capability.SUBPROCESS,), - acknowledge_authorization=True, - max_workers=1, - ), - ) - - collector_ids: list[str] = [] - for item in plan.collectors: - metadata = item.metadata - if metadata is None: - self.fail("planned collector unexpectedly has no metadata") - collector_ids.append(metadata.id) - - self.assertEqual(["mod.example"], collector_ids) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - record = outcome.manifest.collectors[0] - self.assertEqual( - "succeeded", - record.status, - record.errors, - ) - self.assertEqual(2, len(record.artifacts)) - - report_artifact = next(item for item in record.artifacts if item["name"] == "report.txt") - - artifact_path = outcome.run_directory / "artifacts" / str(report_artifact["relative_path"]) - - self.assertEqual( - "mod evidence\n", - artifact_path.read_text(encoding="utf-8"), - ) - - package = outcome.manifest.package - if package is None: - self.fail("successful MOD run unexpectedly has no package metadata") - - mods_package = Path(package["mods_path"]) - mods_hash = Path(package["mods_sha256_path"]) - - self.assertEqual(f"mods-{outcome.run_directory.name}.zip", mods_package.name) - self.assertTrue(mods_hash.is_file()) - - with zipfile.ZipFile(mods_package) as archive: - names = archive.namelist() - - self.assertIn( - "metadata/mods.json", - names, - ) - self.assertTrue(any(name.endswith("/report.txt") for name in names)) - self.assertIsNone(archive.testzip()) - - def test_python_mod_cannot_mutate_project_configuration_without_write_approval( - self, - ) -> None: - """The Python MOD bootstrap blocks host writes while an independent MOD still succeeds.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configuration_path = root / "logicytics.yaml" - - configuration_path.write_text( - '{"schema_version":4}\n', - encoding="utf-8", - ) - - mods = root / "MODS" - mods.mkdir() - - scripts = { - "good.py": ("from pathlib import Path\nPath('report.txt').write_text('ok', encoding='utf-8')\n"), - "malicious.py": ( - f"from pathlib import Path\nPath({str(configuration_path)!r}).write_text('replaced', encoding='utf-8')\n"), - } - - for filename, source in scripts.items(): - script = mods / filename - script.write_text( - source, - encoding="utf-8", - ) - script.with_suffix(".py.mod.json").write_text( - json.dumps(mod_metadata(script.stem)), - encoding="utf-8", - ) - - report = preflight(root) - self.assertEqual( - 2, - len(report.valid), - report.invalid, - ) - - plan = build_plan( - report, - RunRequest( - enable_mods=True, - approved_capabilities=(Capability.SUBPROCESS,), - acknowledge_authorization=True, - max_workers=2, - ), - ) - - outcome = RunSupervisor( - root, - default_config(root), - ).run(plan) - - records = {record.id: record for record in outcome.manifest.collectors} - - self.assertEqual( - "succeeded", - records["mod.good"].status, - records["mod.good"].errors, - ) - self.assertEqual( - "failed", - records["mod.malicious"].status, - ) - self.assertTrue( - any("private workspace" in error for error in records["mod.malicious"].errors), - records["mod.malicious"].errors, - ) - - self.assertEqual( - '{"schema_version":4}\n', - configuration_path.read_text(encoding="utf-8"), - ) - - def test_non_python_mod_files_are_rejected_before_sidecar_processing(self) -> None: - """MODS rejects former native script formats before metadata can enable them.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - - script = mods / "native.bat" - script.write_text( - "@echo off\n", - encoding="utf-8", - ) - script.with_suffix(".bat.mod.json").write_text( - json.dumps(mod_metadata("native", filesystem_write=True)), - encoding="utf-8", - ) - - report = preflight(root) - - self.assertEqual(1, len(report.invalid)) - self.assertIn( - "MODS supports Python (.py) scripts only", - report.invalid[0].static_errors, - ) - - def test_mods_flag_selects_python_mods_and_rejects_nopy(self) -> None: - """The explicit MODS flag accepts Python sidecars and rejects non-Python aliases.""" - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - mods = root / "MODS" - mods.mkdir() - - script = mods / "python_mod.py" - script.write_text("pass\n", encoding="utf-8") - script.with_suffix(".py.mod.json").write_text( - json.dumps(mod_metadata("python_mod")), - encoding="utf-8", - ) - - report = preflight(root) - self.assertEqual(1, len(report.valid), report.invalid) - - extensions = build_plan( - report, - RunRequest( - enable_mods=True, - ), - ) - self.assertEqual( - ("mod.python_mod",), - tuple(item.metadata.id for item in extensions.collectors if item.metadata is not None), - ) - - parser = cli_methods.parser() - mods_request = cli_methods.request( - parser.parse_args(["run", "--mods"]), - 2, - ) - self.assertTrue(mods_request.enable_mods) - with self.assertRaises(SystemExit): - parser.parse_args(["run", "--nopy"]) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_shipped_collectors.py b/tests/test_shipped_collectors.py index 369fbbf3..180b5d8e 100644 --- a/tests/test_shipped_collectors.py +++ b/tests/test_shipped_collectors.py @@ -510,7 +510,6 @@ def test_migration_documentation_covers_every_supported_bridge( for bridge in ( "CODE/config.ini", "core.integration.legacy_code_outputs", - "MODS/", ): self.assertIn( bridge, From 63d299a45522e4c9d61ec783cface3c7d955ec0e Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 11 Sep 2026 01:41:35 +0300 Subject: [PATCH 309/316] refactor(package): organize infrastructure modules Move contract and platform boundaries into the engine module package. Keep compatibility aliases for established collector imports while documenting canonical locations. Signed-off-by: DefinetlyNotAI --- CONTRIBUTING.md | 2 +- docs/ARCHITECTURE.md | 6 +- docs/CONTRACTS.md | 68 +++++++++---------- docs/DEVELOPMENT.md | 2 +- docs/PLUGIN_AUTHORING.md | 2 +- logicytics/__init__.py | 11 ++- logicytics/cli/__init__.py | 4 +- logicytics/cli/commands.py | 8 +-- logicytics/cli/installer.py | 2 +- logicytics/cli/tests.py | 4 +- logicytics/module/api.py | 2 +- logicytics/module/artifacts.py | 2 +- logicytics/module/command_runner.py | 2 +- logicytics/module/configuration.py | 2 +- logicytics/{ => module}/contracts.py | 0 logicytics/module/discovery.py | 6 +- logicytics/module/environment.py | 2 +- logicytics/module/logging.py | 2 +- logicytics/module/manifest.py | 4 +- logicytics/module/modes.py | 2 +- logicytics/module/output_contracts.py | 2 +- logicytics/module/packaging.py | 2 +- logicytics/module/planner.py | 2 +- logicytics/{ => module}/platform_adapters.py | 0 logicytics/module/runtime.py | 4 +- logicytics/{ => module}/terminal.py | 0 logicytics/module/validation_worker.py | 2 +- .../{ => module}/virtual_environment.py | 0 tests/test_logging.py | 2 +- 29 files changed, 80 insertions(+), 67 deletions(-) rename logicytics/{ => module}/contracts.py (100%) rename logicytics/{ => module}/platform_adapters.py (100%) rename logicytics/{ => module}/terminal.py (100%) rename logicytics/{ => module}/virtual_environment.py (100%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e077c610..5e05277e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ The canonical pipeline is: `RunRequest` policy and validated metadata. - `logicytics/module/runtime.py` owns per-run and per-worker lifecycle, cancellation, retries, timeouts, failure aggregation, and post-run actions. -- `logicytics/platform_adapters.py` owns host command, process, registry, +- `logicytics/module/platform_adapters.py` owns host command, process, registry, filesystem, network, privilege, and Win32 access. Collectors must use these injectable seams instead of importing host APIs directly. - `logicytics/module/artifacts.py` is the only publication path from collector diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1daca8f1..b5cb83d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ | Folder | Responsibility | |---------------|-----------------------------------------------------------------------------------------------------------------------------------| -| `logicytics/` | Public package, contracts, CLI, configuration, discovery, planning, runtime, logging, manifests, packaging, and platform adapters | +| `logicytics/` | Public package, CLI, and the internal engine modules for contracts, configuration, discovery, planning, runtime, logging, manifests, packaging, and platform adapters | | `core/` | Shipped read-only collector implementations grouped by specialty | | `plugins/` | Opt-in user-owned `PluginCollector` implementations | | `tests/` | Unit, contract, resilience, security, and Windows integration tests | @@ -14,7 +14,7 @@ ## Engine modules -- `contracts.py` defines enums, metadata, request, context, artifact, result, and collector interfaces. +- `module/contracts.py` defines enums, metadata, request, context, artifact, result, and collector interfaces. - `configuration.py` loads and validates `logicytics.yaml`. - `discovery.py` finds candidates, performs static checks, probes metadata, and manages the disposable validation cache. - `planner.py` resolves profiles, selectors, dependencies, capabilities, ordering, and run fingerprints. @@ -23,6 +23,8 @@ - `manifest.py` writes the durable run record. - `packaging.py` builds and verifies ZIP packages and hash sidecars. - `api.py` exposes planning, collection, run queries, and bounded artifact reads. +- `module/platform_adapters.py` owns the injectable host-process and Windows API boundaries. +- `cli/terminal.py` manages console lifecycle and `cli/virtual_environment.py` validates the supported interpreter environment. - `cli/commands.py` maps command-line arguments to the public engine operations. ## Boundary rules diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index 2f40dad0..0abe8ec7 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -1,6 +1,6 @@ # Engine and plugin contracts -`logicytics/contracts.py` is the stable vocabulary shared by the CLI, planner, runtime, core collectors, and the only supported extension type: `PluginCollector`. A contract violation is intentionally rejected during construction, preflight, planning, worker execution, artifact registration, or packaging rather than being silently repaired. +`logicytics/module/contracts.py` is the stable vocabulary shared by the CLI, planner, runtime, core collectors, and the only supported extension type: `PluginCollector`. A contract violation is intentionally rejected during construction, preflight, planning, worker execution, artifact registration, or packaging rather than being silently repaired. ## Identity and ownership @@ -12,36 +12,36 @@ Core IDs use `core..`; plugin IDs use `plugin.`. IDs are Metadata is policy, not display text. It is evaluated before a collector may enter a plan. -| Field | Requirement and effect | -| --- | --- | -| `id`, `name`, `description`, `author` | Non-empty single-line strings. `id` must match its owner and location. | -| `version` | Semantic version, for example `1.2.0`. | -| `specialty` | A core `Specialty` value or a plugin's custom lowercase category. | -| `supported_platforms` | Non-empty tuple of lowercase platform labels; a mismatched current platform rejects the plan. | -| `capabilities` | Tuple of declared `Capability` values. Declared access is visible to planning and can be blocked. | -| `privilege_level` | `standard` or `elevated`; it must agree exactly with the `elevated_privileges` capability. | -| `network_access` | `none`, `local`, or `remote`; `network` capability requires local or remote access. | -| `sensitive_data_categories` | Lowercase labels describing sensitive output. Minimal mode cannot include a sensitive collector. | -| `default_profiles` | Non-empty mode-profile membership. Standard mode admits only routine configuration categories. | -| `dependencies` | Unique collector IDs, never the collector's own ID. Dependencies are ordered and policy-checked. | -| `output_media_types` | Non-empty unique MIME types. Every registered artifact must use one of these types. | -| `timeout_seconds` | Positive worker time limit. | -| `maximum_memory_bytes`, `maximum_output_bytes` | Positive per-collector resource ceilings. | +| Field | Requirement and effect | +|----------------------------------------------------|----------------------------------------------------------------------------------------------------------------| +| `id`, `name`, `description`, `author` | Non-empty single-line strings. `id` must match its owner and location. | +| `version` | Semantic version, for example `1.2.0`. | +| `specialty` | A core `Specialty` value or a plugin's custom lowercase category. | +| `supported_platforms` | Non-empty tuple of lowercase platform labels; a mismatched current platform rejects the plan. | +| `capabilities` | Tuple of declared `Capability` values. Declared access is visible to planning and can be blocked. | +| `privilege_level` | `standard` or `elevated`; it must agree exactly with the `elevated_privileges` capability. | +| `network_access` | `none`, `local`, or `remote`; `network` capability requires local or remote access. | +| `sensitive_data_categories` | Lowercase labels describing sensitive output. Minimal mode cannot include a sensitive collector. | +| `default_profiles` | Non-empty mode-profile membership. Standard mode admits only routine configuration categories. | +| `dependencies` | Unique collector IDs, never the collector's own ID. Dependencies are ordered and policy-checked. | +| `output_media_types` | Non-empty unique MIME types. Every registered artifact must use one of these types. | +| `timeout_seconds` | Positive worker time limit. | +| `maximum_memory_bytes`, `maximum_output_bytes` | Positive per-collector resource ceilings. | | `maximum_artifact_bytes`, `maximum_artifact_files` | Positive artifact limits; a missing per-artifact byte limit defaults to the output limit and cannot exceed it. | -| `maximum_retries`, `retry_delay_seconds` | Bounded retry policy: retries are 0–3 and delay is finite from 0–30 seconds. | -| `minimum_contract_version` | A valid `major.minor` contract version; the current contract is `4.0`. | -| `parallel_safe`, `resource_class` | Scheduling declaration. A resource class prevents unsafe overlap with conflicting work. | +| `maximum_retries`, `retry_delay_seconds` | Bounded retry policy: retries are 0–3 and delay is finite from 0–30 seconds. | +| `minimum_contract_version` | A valid `major.minor` contract version; the current contract is `4.0`. | +| `parallel_safe`, `resource_class` | Scheduling declaration. A resource class prevents unsafe overlap with conflicting work. | ### Capability values -| Capability | Declare it when the collector needs to | -| --- | --- | -| `filesystem_read` / `filesystem_write` | Read host files or write outside its supplied private workspace. | -| `registry_read` | Read Windows registry state. | -| `subprocess` | Invoke a host executable or shell-free command. | -| `network` / `packet_capture` | Reach the network or capture packet metadata. | -| `browser_data`, `sensitive_files`, `private_keys` | Handle those sensitive data categories. | -| `elevated_privileges` | Require an administrator context; metadata must also set `privilege_level=elevated`. | +| Capability | Declare it when the collector needs to | +|---------------------------------------------------|--------------------------------------------------------------------------------------| +| `filesystem_read` / `filesystem_write` | Read host files or write outside its supplied private workspace. | +| `registry_read` | Read Windows registry state. | +| `subprocess` | Invoke a host executable or shell-free command. | +| `network` / `packet_capture` | Reach the network or capture packet metadata. | +| `browser_data`, `sensitive_files`, `private_keys` | Handle those sensitive data categories. | +| `elevated_privileges` | Require an administrator context; metadata must also set `privilege_level=elevated`. | The planner rejects selected collectors with a request-blocked capability. Offline mode additionally rejects `network` and `packet_capture` collectors. Capability declaration does not grant permission to evade the worker boundary. @@ -74,13 +74,13 @@ Preflight also enforces the public class shape. Do not add import-time work, dyn `CollectorResult` uses one terminal `CollectorStatus`: -| Status | Use it when | -| --- | --- | -| `succeeded` | The requested work completed and registered its declared output. | -| `partial` | Useful registered evidence exists, but a meaningful portion could not complete. | -| `skipped` | A normal prerequisite, device, feature, or permission is unavailable. | -| `cancelled` | The engine or user cancellation boundary was reached. | -| `failed` | The collector encountered an execution or contract error. | +| Status | Use it when | +|-------------|---------------------------------------------------------------------------------| +| `succeeded` | The requested work completed and registered its declared output. | +| `partial` | Useful registered evidence exists, but a meaningful portion could not complete. | +| `skipped` | A normal prerequisite, device, feature, or permission is unavailable. | +| `cancelled` | The engine or user cancellation boundary was reached. | +| `failed` | The collector encountered an execution or contract error. | Always provide a non-empty summary. Attach structured errors and scalar metrics where they aid review. Do not describe a partial or skipped result as success. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c3d43938..b607f42a 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -7,7 +7,7 @@ changing engine or collector code. Keep the standard-library-first installer pat ## Change boundaries -Engine changes belong under `logicytics/module/`; contract changes belong in `logicytics/contracts.py`; shipped +Engine changes and contract changes belong under `logicytics/module/`; shipped collection belongs under the matching `core//` directory; user extensions belong in `plugins/`; documentation changes belong in `docs/`. Do not commit collected evidence, caches, credentials, or local manifests. diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index e63fdefb..a303df0f 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -6,7 +6,7 @@ enabled with `--plugins`. It is deliberately opt-in and is isolated like a core ## Minimal plugin ```python -from logicytics.contracts import ( +from logicytics.module.contracts import ( CollectorMetadata, CollectorResult, CollectorStatus, CollectorContext, PluginCollector, ValidationResult, ArtifactWriter, ) diff --git a/logicytics/__init__.py b/logicytics/__init__.py index 543b1cbc..a38da61d 100644 --- a/logicytics/__init__.py +++ b/logicytics/__init__.py @@ -1,9 +1,10 @@ """Logicytics v4 public contracts and lazily loaded application API.""" import importlib +import sys from typing import TYPE_CHECKING -from logicytics.contracts import ( +from logicytics.module.contracts import ( CONTRACT_VERSION, Capability, CollectionEstimate, @@ -52,6 +53,14 @@ } ) +# Keep the former import paths working for shipped collectors and independently +# authored plugins while the implementation lives in its domain subpackages. +for _legacy_name, _module_name in { + "contracts": "logicytics.module.contracts", + "platform_adapters": "logicytics.module.platform_adapters", +}.items(): + sys.modules.setdefault(f"{__name__}.{_legacy_name}", importlib.import_module(_module_name)) + def __getattr__(name: str): """Load application services and global host infrastructure on explicit access.""" diff --git a/logicytics/cli/__init__.py b/logicytics/cli/__init__.py index 0f154be8..6e73ecad 100644 --- a/logicytics/cli/__init__.py +++ b/logicytics/cli/__init__.py @@ -6,8 +6,8 @@ from pathlib import Path from logicytics.cli.commands import cli_methods, CLI -from logicytics.terminal import terminal_lifecycle -from logicytics.virtual_environment import ( +from logicytics.module.terminal import terminal_lifecycle +from logicytics.module.virtual_environment import ( is_running_in_virtual_environment, render_virtual_environment_error, ) diff --git a/logicytics/cli/commands.py b/logicytics/cli/commands.py index 45e0374e..69e96950 100644 --- a/logicytics/cli/commands.py +++ b/logicytics/cli/commands.py @@ -15,8 +15,8 @@ from time import perf_counter from typing import Literal -from logicytics.contracts import Capability, OutputPolicy, PostRunAction, RunRequest from logicytics.module.configuration import AppConfig, load_config +from logicytics.module.contracts import Capability, OutputPolicy, PostRunAction, RunRequest from logicytics.module.discovery import preflight from logicytics.module.environment import inspect_environment from logicytics.module.errors import LogicyticsError @@ -53,12 +53,12 @@ ) from logicytics.module.output_layout import ensure_output_layout from logicytics.module.planner import build_plan +from logicytics.module.platform_adapters import process_adapter from logicytics.module.runtime import RunSupervisor from logicytics.module.sysinternals import ensure_sysinternals +from logicytics.module.terminal import terminal_lifecycle from logicytics.module.usb import ensure_usb_storage, find_windows_installation -from logicytics.platform_adapters import process_adapter -from logicytics.terminal import terminal_lifecycle -from logicytics.virtual_environment import ( +from logicytics.module.virtual_environment import ( is_running_in_virtual_environment, virtual_environment_details, virtual_environment_error, diff --git a/logicytics/cli/installer.py b/logicytics/cli/installer.py index 95b16ef5..bc2687ff 100644 --- a/logicytics/cli/installer.py +++ b/logicytics/cli/installer.py @@ -8,7 +8,7 @@ from logicytics.module.configuration import write_default_configuration from logicytics.module.logging import ApplicationLogger, HumanArgumentParser -from logicytics.terminal import terminal_lifecycle +from logicytics.module.terminal import terminal_lifecycle def project_root() -> Path: diff --git a/logicytics/cli/tests.py b/logicytics/cli/tests.py index b4d0caf8..c46d8fce 100644 --- a/logicytics/cli/tests.py +++ b/logicytics/cli/tests.py @@ -13,8 +13,8 @@ if TYPE_CHECKING: from unittest.runner import _ResultClassType -from logicytics.terminal import isolated_terminal_lifecycle, terminal_lifecycle -from logicytics.virtual_environment import ( +from logicytics.module.terminal import isolated_terminal_lifecycle, terminal_lifecycle +from logicytics.module.virtual_environment import ( is_running_in_virtual_environment, render_virtual_environment_error, ) diff --git a/logicytics/module/api.py b/logicytics/module/api.py index 345e2141..2b72d1c9 100644 --- a/logicytics/module/api.py +++ b/logicytics/module/api.py @@ -13,7 +13,7 @@ from pathlib import Path, PurePosixPath from typing import Any -from logicytics.contracts import Artifact, CollectorStatus, RunRequest, RunStatus +from logicytics.module.contracts import Artifact, CollectorStatus, RunRequest, RunStatus from logicytics.module.configuration import AppConfig, load_config from logicytics.module.discovery import preflight from logicytics.module.errors import ArtifactError, PlanError diff --git a/logicytics/module/artifacts.py b/logicytics/module/artifacts.py index 436cd48e..8efc1d11 100644 --- a/logicytics/module/artifacts.py +++ b/logicytics/module/artifacts.py @@ -12,7 +12,7 @@ from threading import RLock from uuid import uuid4 -from logicytics.contracts import Artifact, ArtifactWriter, EvidenceKind +from logicytics.module.contracts import Artifact, ArtifactWriter, EvidenceKind from logicytics.module.errors import ArtifactError diff --git a/logicytics/module/command_runner.py b/logicytics/module/command_runner.py index ebb46c00..f63267ac 100644 --- a/logicytics/module/command_runner.py +++ b/logicytics/module/command_runner.py @@ -5,7 +5,7 @@ from collections.abc import Iterable from dataclasses import dataclass -from logicytics.platform_adapters import process_adapter +from logicytics.module.platform_adapters import process_adapter @dataclass(frozen=True, slots=True) diff --git a/logicytics/module/configuration.py b/logicytics/module/configuration.py index 9976a67f..53b914e9 100644 --- a/logicytics/module/configuration.py +++ b/logicytics/module/configuration.py @@ -12,7 +12,7 @@ from types import MappingProxyType from typing import Any -from logicytics.contracts import Capability +from logicytics.module.contracts import Capability from logicytics.module.errors import PlanError from logicytics.module.redaction import redact_mapping diff --git a/logicytics/contracts.py b/logicytics/module/contracts.py similarity index 100% rename from logicytics/contracts.py rename to logicytics/module/contracts.py diff --git a/logicytics/module/discovery.py b/logicytics/module/discovery.py index f522699c..62665b0b 100644 --- a/logicytics/module/discovery.py +++ b/logicytics/module/discovery.py @@ -14,14 +14,14 @@ from pathlib import Path from subprocess import TimeoutExpired -from logicytics.contracts import ( +from logicytics.module.contracts import ( CONTRACT_VERSION, Capability, CollectorKind, CollectorMetadata, Specialty, ) -from logicytics.platform_adapters import process_adapter +from logicytics.module.platform_adapters import process_adapter _FILENAME = re.compile(r"^[a-z][a-z0-9_]*\.py$") _VAGUE_NAMES = {"main.py", "misc.py", "stuff.py", "utils.py"} @@ -48,8 +48,10 @@ _CACHE_SCHEMA_VERSION = 2 _COLLECTOR_SERVICE_MODULES = { "logicytics.contracts", + "logicytics.module.contracts", "logicytics.global.ctypes_collector", "logicytics.platform_adapters", + "logicytics.module.platform_adapters", } PreflightProgress = Callable[[str, int, int, str], None] diff --git a/logicytics/module/environment.py b/logicytics/module/environment.py index 86fe85bd..036c77b9 100644 --- a/logicytics/module/environment.py +++ b/logicytics/module/environment.py @@ -4,7 +4,7 @@ from dataclasses import asdict, dataclass -from logicytics.platform_adapters import ( +from logicytics.module.platform_adapters import ( process_adapter, registry_adapter, which, diff --git a/logicytics/module/logging.py b/logicytics/module/logging.py index 8115a0c1..f3718438 100644 --- a/logicytics/module/logging.py +++ b/logicytics/module/logging.py @@ -17,7 +17,7 @@ from time import perf_counter, time from typing import ParamSpec, TextIO, TypeVar -from logicytics.contracts import EventLogger +from logicytics.module.contracts import EventLogger from logicytics.module.configuration import LoggingSettings from logicytics.module.presentation import ( console_width as presentation_console_width, diff --git a/logicytics/module/manifest.py b/logicytics/module/manifest.py index 380d60a4..1ce3a1f7 100644 --- a/logicytics/module/manifest.py +++ b/logicytics/module/manifest.py @@ -14,9 +14,9 @@ from time import sleep from typing import Any -from logicytics.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus +from logicytics.module.contracts import CONTRACT_VERSION, Artifact, CollectorResult, RunStatus from logicytics.module.redaction import redact_mapping, redact_text -from logicytics.platform_adapters import windows_api_adapter +from logicytics.module.platform_adapters import windows_api_adapter MANIFEST_SCHEMA_VERSION = 1 _MANIFEST_REPLACE_DELAYS = (0.05, 0.1, 0.2, 0.4, 0.8, 1.6, 3.2) diff --git a/logicytics/module/modes.py b/logicytics/module/modes.py index dc629e53..c5ffedf1 100644 --- a/logicytics/module/modes.py +++ b/logicytics/module/modes.py @@ -8,7 +8,7 @@ from types import MappingProxyType from typing import TypedDict -from logicytics.contracts import CollectorKind +from logicytics.module.contracts import CollectorKind from logicytics.module.discovery import CollectorCandidate diff --git a/logicytics/module/output_contracts.py b/logicytics/module/output_contracts.py index b71c6bdf..ad0cdb80 100644 --- a/logicytics/module/output_contracts.py +++ b/logicytics/module/output_contracts.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from logicytics.contracts import CollectorMetadata +from logicytics.module.contracts import CollectorMetadata _MEDIA_SUFFIX = { "application/json": ".json", diff --git a/logicytics/module/packaging.py b/logicytics/module/packaging.py index 2a9ff629..33628824 100644 --- a/logicytics/module/packaging.py +++ b/logicytics/module/packaging.py @@ -11,7 +11,7 @@ from pathlib import Path, PurePosixPath from typing import IO, TYPE_CHECKING -from logicytics.contracts import Artifact +from logicytics.module.contracts import Artifact from logicytics.module.artifacts import sha256_file from logicytics.module.manifest import RunManifest, write_manifest diff --git a/logicytics/module/planner.py b/logicytics/module/planner.py index 92ee33b0..f1c94226 100644 --- a/logicytics/module/planner.py +++ b/logicytics/module/planner.py @@ -8,7 +8,7 @@ from dataclasses import asdict, dataclass from types import MappingProxyType -from logicytics.contracts import Capability, CollectorKind, RunRequest +from logicytics.module.contracts import Capability, CollectorKind, RunRequest from logicytics.module.discovery import CollectorCandidate, PreflightReport from logicytics.module.environment import inspect_environment from logicytics.module.errors import PlanError, PreflightError diff --git a/logicytics/platform_adapters.py b/logicytics/module/platform_adapters.py similarity index 100% rename from logicytics/platform_adapters.py rename to logicytics/module/platform_adapters.py diff --git a/logicytics/module/runtime.py b/logicytics/module/runtime.py index c0762c80..2e52cf44 100644 --- a/logicytics/module/runtime.py +++ b/logicytics/module/runtime.py @@ -26,7 +26,7 @@ from typing import Any, Protocol, TypedDict, TypeVar, cast from uuid import uuid4 -from logicytics.contracts import ( +from logicytics.module.contracts import ( Artifact, Capability, CollectorContext, @@ -50,7 +50,7 @@ from logicytics.module.output_layout import allocate_output_run_directory, ensure_output_layout from logicytics.module.packaging import package_manifest from logicytics.module.planner import RunPlan -from logicytics.platform_adapters import process_adapter, windows_api_adapter +from logicytics.module.platform_adapters import process_adapter, windows_api_adapter _T = TypeVar("_T") diff --git a/logicytics/terminal.py b/logicytics/module/terminal.py similarity index 100% rename from logicytics/terminal.py rename to logicytics/module/terminal.py diff --git a/logicytics/module/validation_worker.py b/logicytics/module/validation_worker.py index ee5ff5fe..95a57aa8 100644 --- a/logicytics/module/validation_worker.py +++ b/logicytics/module/validation_worker.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import cast -from logicytics.contracts import ( +from logicytics.module.contracts import ( Artifact, ArtifactWriter, Collector, diff --git a/logicytics/virtual_environment.py b/logicytics/module/virtual_environment.py similarity index 100% rename from logicytics/virtual_environment.py rename to logicytics/module/virtual_environment.py diff --git a/tests/test_logging.py b/tests/test_logging.py index df9b2dba..a416ec6f 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -29,7 +29,7 @@ timed, ) from logicytics.module.output_layout import ensure_output_layout -from logicytics.virtual_environment import ( +from logicytics.module.virtual_environment import ( render_virtual_environment_error, virtual_environment_error, ) From f6fbdf4ee4b643ad4781c6e1d4e91368e0b4b32c Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 20:30:10 +0300 Subject: [PATCH 310/316] fix(platform): defer Windows ctypes imports --- logicytics/module/platform_adapters.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/logicytics/module/platform_adapters.py b/logicytics/module/platform_adapters.py index f837e87c..d67200d8 100644 --- a/logicytics/module/platform_adapters.py +++ b/logicytics/module/platform_adapters.py @@ -16,9 +16,10 @@ from pathlib import Path from typing import Any, Literal, overload -_ctypes_collector = importlib.import_module("logicytics.global.ctypes_collector") -query_registry_key_info = _ctypes_collector.query_registry_key_info -filetime = _ctypes_collector.filetime +def _windows_registry_bindings(): + """Load Windows-only registry bindings only when registry access is requested.""" + bindings = importlib.import_module("logicytics.global.ctypes_collector") + return bindings.filetime, bindings.query_registry_key_info try: import winreg as _winreg @@ -227,6 +228,7 @@ def last_write_time(key: Any) -> str | None: if _winreg is None: return None + filetime, query_registry_key_info = _windows_registry_bindings() timestamp = filetime() result = query_registry_key_info( key.handle, From 4e8ee2bd9d97450f76a401ecc4cc3cfbdd959cd3 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 20:30:30 +0300 Subject: [PATCH 311/316] fix(tests): import terminal from module package --- tests/test_terminal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_terminal.py b/tests/test_terminal.py index b1170784..6f109f09 100644 --- a/tests/test_terminal.py +++ b/tests/test_terminal.py @@ -6,7 +6,7 @@ import unittest from unittest.mock import patch -from logicytics import terminal +from logicytics.module import terminal from logicytics.module.presentation import render_banner From 4cce99ea7788ad0559e57e068358ee45d943c330 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 20:31:06 +0300 Subject: [PATCH 312/316] fix(sysinternals): bound archive downloads --- logicytics/module/sysinternals.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/logicytics/module/sysinternals.py b/logicytics/module/sysinternals.py index bf89dfd7..40df7ab7 100644 --- a/logicytics/module/sysinternals.py +++ b/logicytics/module/sysinternals.py @@ -11,6 +11,8 @@ from logicytics.module.configuration import MaintenanceSettings +MAX_ARCHIVE_BYTES = 128 * 1024 * 1024 + @dataclass(frozen=True, slots=True) class SysinternalsState: @@ -42,16 +44,26 @@ def ensure_sysinternals( return SysinternalsState("extracted", archive, extraction_directory) if not archive.is_file(): archive.parent.mkdir(parents=True, exist_ok=True) + downloaded: Path | None = None try: with ( urlopen(settings.sysinternals_download_url, timeout=30) as response, NamedTemporaryFile(mode="wb", dir=archive.parent, delete=False) as temporary, ): - temporary.write(response.read()) + content_length = response.headers.get("Content-Length") + if content_length is not None and int(content_length) > MAX_ARCHIVE_BYTES: + raise ValueError("download exceeds the maximum archive size") + total = 0 + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > MAX_ARCHIVE_BYTES: + raise ValueError("download exceeds the maximum archive size") + temporary.write(chunk) downloaded = Path(temporary.name) - except (OSError, URLError) as error: + except (OSError, URLError, ValueError) as error: return SysinternalsState(f"download_failed: {error}", archive, extraction_directory) try: + assert downloaded is not None if not zipfile.is_zipfile(downloaded): return SysinternalsState("download_failed: archive is not a ZIP file", archive, extraction_directory) downloaded.replace(archive) From c1e9559fe0b4ef58f543e4194abd1136442675d4 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 20:31:41 +0300 Subject: [PATCH 313/316] fix(bios): align HTML golden output --- tests/golden/bios_info.html | 50 +++++++++++-------------------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/tests/golden/bios_info.html b/tests/golden/bios_info.html index ecafef39..e05c0e3c 100644 --- a/tests/golden/bios_info.html +++ b/tests/golden/bios_info.html @@ -1,39 +1,17 @@ - - - BIOS information - -

BIOS information

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
- + BIOS information + +

BIOS information

+ + + + + + + + + +
FieldValue
ManufacturerExample & Sons
NameGolden <BIOS>
SMBIOS BIOS version1.2.3
VersionEXAMPLE - 1
Release date2026-01-02
+ From ddc8c0270ec097b8e86b02de7f3a51fdd951ff22 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 20:32:10 +0300 Subject: [PATCH 314/316] test(sysinternals): isolate timeout fixture from host binaries --- tests/test_deep_collector_resilience.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_deep_collector_resilience.py b/tests/test_deep_collector_resilience.py index 2898f84b..40249124 100644 --- a/tests/test_deep_collector_resilience.py +++ b/tests/test_deep_collector_resilience.py @@ -57,6 +57,10 @@ def test_sysinternals_timeouts_are_recorded_in_the_report(self) -> None: sysinternals_report.subprocess, sysinternals_report.subprocess.run.__name__, side_effect=subprocess.TimeoutExpired(["psfile.exe"], 25), + ), patch.object( + sysinternals_report.Path, + "is_file", + return_value=True, ): result = sysinternals_report.SysinternalsReportCollector().collect(context) report = (context.workspace / "sysinternals_report.txt").read_text(encoding="utf-8") From 39fdf99c0b22a8bf31af500ec73a01083cfe0429 Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 20:32:56 +0300 Subject: [PATCH 315/316] fix(sysinternals): clean failed archive downloads --- logicytics/module/sysinternals.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/logicytics/module/sysinternals.py b/logicytics/module/sysinternals.py index 40df7ab7..ecbe5726 100644 --- a/logicytics/module/sysinternals.py +++ b/logicytics/module/sysinternals.py @@ -50,6 +50,7 @@ def ensure_sysinternals( urlopen(settings.sysinternals_download_url, timeout=30) as response, NamedTemporaryFile(mode="wb", dir=archive.parent, delete=False) as temporary, ): + downloaded = Path(temporary.name) content_length = response.headers.get("Content-Length") if content_length is not None and int(content_length) > MAX_ARCHIVE_BYTES: raise ValueError("download exceeds the maximum archive size") @@ -59,8 +60,9 @@ def ensure_sysinternals( if total > MAX_ARCHIVE_BYTES: raise ValueError("download exceeds the maximum archive size") temporary.write(chunk) - downloaded = Path(temporary.name) except (OSError, URLError, ValueError) as error: + if downloaded is not None: + downloaded.unlink(missing_ok=True) return SysinternalsState(f"download_failed: {error}", archive, extraction_directory) try: assert downloaded is not None From 51b06ed111ab9c42218249bbd522dffe44be056f Mon Sep 17 00:00:00 2001 From: DefinetlyNotAI Date: Fri, 25 Sep 2026 21:21:30 +0300 Subject: [PATCH 316/316] chore: updated release date --- SECURITY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index bb5aa280..39ab0d78 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,8 +3,8 @@ ## Supported versions | Version | Security support | Release date | -|---------|------------------|--------------------| -| 4.0.x | Supported | September XX, 2026 | +| ------- | ---------------- | ------------------ | +| 4.0.x | Supported | September 25, 2026 | | 3.6.x | Partial Support | July 26, 2025 | | 3.5.x | Partial Support | July 26, 2025 | | 3.4.x | Partial Support | January 3, 2025 |

JsFqyb9}_w+2e{%78%vS9#>QL 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�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