`,
+# skip the whole subtree.
+# 4. Name exclude (any depth) — if the directory's *basename*
+# matches `exclude_names_universal`, skip subtree.
+# 5. Hidden directory rule — if `basename` starts with "." AND
+# isn't in `exclude_hidden_names_always`:
+# a. Peek for `.git/` directly inside.
+# b. If found -> treat as a discovered repo.
+# c. If not -> skip subtree.
+# 6. Repo boundary — if the current directory itself contains
+# `.git/`, treat as a repo root and do not recurse further.
+#
+# A/B TEST NOTE: v1.0.0 (HOME-only) is preserved at the bottom
+# of this file as a commented block. To revert to v1, uncomment
+# that section and comment out everything above it.
# =============================================================
-# Skip these names anywhere they appear in the path. Walks bail at
-# repo boundaries (a dir containing `.git/` is treated as a repo
-# root and never recursed into).
-exclude_names:
- # Vendored / build / cache dirs
+version: 2.0.0
+
+extra_roots:
+ windows:
+ drive_relative:
+ - Code
+ - Codebase
+ - Repo
+ - Repos
+ - Repositories
+ - Workspace
+ - Workspaces
+ - Projects
+ - Project
+ - Dev
+ - Development
+ - Source
+ - Sources
+ - src
+ - Git
+ - GitHub
+ - work
+ - Work
+ absolute:
+ - C:\dev
+ - C:\src
+ - D:\dev
+ - D:\src
+ - E:\dev
+ - E:\src
+
+ macos:
+ home_relative:
+ - Code
+ - Codebase
+ - Repos
+ - Repositories
+ - Workspace
+ - Projects
+ - Dev
+ - Development
+ - src
+ - work
+ - GitHub
+ - Documents/Code
+ - Documents/Repos
+ - Documents/GitHub
+ absolute:
+ - /Users/Shared/Code
+ - /Users/Shared/Repos
+ skip_volumes:
+ - "Macintosh HD"
+ - "Recovery"
+ - "Preboot"
+ - "VM"
+ - "Update"
+ - "xarts"
+ - "iSCPreboot"
+
+ linux:
+ home_relative:
+ - Code
+ - Codebase
+ - Repos
+ - Repositories
+ - Workspace
+ - Projects
+ - Dev
+ - Development
+ - src
+ - work
+ - GitHub
+ absolute:
+ - /opt/code
+ - /opt/workspace
+ - /opt/repos
+ - /srv/code
+ - /srv/workspace
+ - /data/code
+ - /data/repos
+ - /data/workspace
+ - /workspace
+ - /code
+ skip_mounts:
+ - lost+found
+ - cdrom
+ - dvd
+
+exclude_absolute_paths_windows:
+ - C:\Windows
+ - C:\Program Files
+ - C:\Program Files (x86)
+ - C:\ProgramData
+ - C:\$Recycle.Bin
+ - C:\System Volume Information
+ - C:\Recovery
+ - C:\PerfLogs
+ - C:\Boot
+ - C:\MSOCache
+ - C:\Config.Msi
+ - C:\Drivers
+ - C:\Dell
+ - C:\HP
+ - C:\Lenovo
+ - C:\Intel
+ - C:\NVIDIA
+ - C:\AMD
+ - C:\OEM
+ - C:\Users\Default
+ - C:\Users\Default User
+ - C:\Users\Public
+ - C:\Users\All Users
+ - "%USERPROFILE%\\AppData"
+
+exclude_absolute_paths_macos:
+ - /System
+ - /Library
+ - /Applications
+ - /usr
+ - /bin
+ - /sbin
+ - /etc
+ - /var
+ - /private
+ - /dev
+ - /cores
+ - /Network
+ - /Volumes/Recovery
+ - /Volumes/Preboot
+ - /Volumes/VM
+ - "$HOME/Library"
+ - "$HOME/Applications"
+ - "$HOME/Music"
+ - "$HOME/Pictures"
+ - "$HOME/Movies"
+ - "$HOME/Public"
+ - "$HOME/.config/gcloud"
+
+exclude_absolute_paths_linux:
+ - /proc
+ - /sys
+ - /dev
+ - /run
+ - /boot
+ - /usr
+ - /var
+ - /etc
+ - /lib
+ - /lib32
+ - /lib64
+ - /sbin
+ - /bin
+ - /lost+found
+ - /tmp
+ - /var/tmp
+ - /snap
+ - /flatpak
+ - /.snapshots
+ - "$HOME/.local/share/Trash"
+ - "$HOME/snap"
+ - "$HOME/.config/gcloud"
+
+exclude_names_universal:
+ # Vendored / package-manager caches
- node_modules
- - .venv
- - venv
+ - bower_components
- vendor
- - __pycache__
- - .tox
- - .gradle
- - .m2
- - .cargo
- - .cache
- - .docker
- .npm
- .pnpm
- .yarn
+ - .pnpm-store
+ - .nuget
+ - .gradle
+ - .m2
+ - .ivy2
+ - .sbt
+ - .cargo
- .rustup
- .pyenv
- .rbenv
- # System dirs (macOS)
- - Library
- - Applications
+ - .nvm
+ - .deno
+ - .bun
+ - .composer
+ # Python venvs / caches
+ - .venv
+ - venv
+ - env
+ - .virtualenv
+ - virtualenv
+ - __pycache__
+ - .mypy_cache
+ - .pytest_cache
+ - .ruff_cache
+ - .tox
+ # Build outputs
+ - .next
+ - .nuxt
+ - .svelte-kit
+ - .parcel-cache
+ - .vite
+ - .turbo
+ - .cache-loader
+ # Container / orchestration state
+ - .docker
+ - .podman
+ - .kube
+ - .minikube
+ - .helm
+ - .colima
+ - .terraform
+ - .terragrunt-cache
+ # IDE / editor state
+ - .idea
+ - .vs
+ - .vscode-server
+ - .history
+ - .ipynb_checkpoints
+ # AI-tool state
+ - .copilot
+ - .codeium
+ - .continue
+ - .cline
+ - .aider
+ - .cursor-server
+ # Coverage / test artefacts
+ - coverage
+ - htmlcov
+ - .coverage
+ - .nyc_output
+ # Misc OS noise
- .Trash
- - private
- # Personal media trees — almost never contain code
- - Pictures
- - Music
- - Movies
- - Public
-
-# Hard limit — never recurse below this depth from $HOME.
+ - .Trash-1000
+ - "$RECYCLE.BIN"
+ - "System Volume Information"
+
+exclude_hidden_names_always:
+ - .ssh
+ - .gnupg
+ - .password-store
+ - .aws
+ - .azure
+ - .gcloud
+ - .kube
+ - .docker
+ - .pulumi
+ - .terraform.d
+ - .netrc
+ - .git-credentials
+
+# RESERVED — not yet wired into scan_repo_discovery.py.frag.
+# These hidden directories ARE git repos and should be discovered,
+# not excluded. Once the walker gains dotfile-repo support these will
+# be used to explicitly allow-list them through the hidden-directory rule.
+known_dotfile_repos:
+ - .dotfiles
+ - .dotfiles-private
+ - .config
+ - .emacs.d
+ - .vim
+ - .neovim
+ - .nvim
+ - .oh-my-zsh
+ - .zsh
+ - .bash_it
+ - .tmux
+
max_depth: 6
+max_seconds: 90
-# Hard time cap — if the walk hasn't completed by this many seconds,
-# bail and ship whatever was found. Prevents a runaway walk on
-# pathological filesystems.
-max_seconds: 60
+# RESERVED — not yet consumed by the heartbeat daemon.
+# When implemented, the agent will run a full multi-root walk every N
+# heartbeat cycles instead of only scanning incremental changes.
+backstop_full_walk_every_n_heartbeats: 12
-# Known repo-discovery roots — informational only today (the walker
-# starts from $HOME). Listed here so OSS contributors with non-standard
-# layouts know what gets scanned by default.
-documented_default_roots:
- - "$HOME"
+# =============================================================
+# v1.0.0 (A/B TEST — original HOME-only walker)
+# Uncomment below and comment out everything above to revert.
+# =============================================================
+# version: 1.0.0
+# exclude_names:
+# - node_modules
+# - .venv
+# - venv
+# - vendor
+# - __pycache__
+# - .tox
+# - .gradle
+# - .m2
+# - .cargo
+# - .cache
+# - .docker
+# - .npm
+# - .pnpm
+# - .yarn
+# - .rustup
+# - .pyenv
+# - .rbenv
+# - Library
+# - Applications
+# - .Trash
+# - private
+# - Pictures
+# - Music
+# - Movies
+# - Public
+# max_depth: 6
+# max_seconds: 60
+# documented_default_roots:
+# - "$HOME"
diff --git a/ghost-ai-scanner/scripts/render_agent_package.py b/ghost-ai-scanner/scripts/render_agent_package.py
index ef78169..5185501 100644
--- a/ghost-ai-scanner/scripts/render_agent_package.py
+++ b/ghost-ai-scanner/scripts/render_agent_package.py
@@ -187,8 +187,15 @@ def render_agent_package(
return {"success": False, "error": str(e)}
# ── EC2-side artifact builds ──────────────────────────────
- dmg_key = _build_macos_dmg(sh_script, recipient_name, token, store)
- exe_key = _build_windows_exe(ps1_script, recipient_name, token, store)
+ dmg_key, exe_key = "", ""
+ try:
+ dmg_key = _build_macos_dmg(sh_script, recipient_name, token, store)
+ except Exception as e:
+ log.error("DMG build failed for token %s: %s", token[:8], e)
+ try:
+ exe_key = _build_windows_exe(ps1_script, recipient_name, token, store)
+ except Exception as e:
+ log.error("EXE build failed for token %s: %s", token[:8], e)
dmg_url = store.get_artifact_url(dmg_key) if dmg_key else ""
exe_url = store.get_artifact_url(exe_key) if exe_key else ""
diff --git a/ghost-ai-scanner/src/ingestor/ingestor.py b/ghost-ai-scanner/src/ingestor/ingestor.py
index f2b7b13..5afe84c 100644
--- a/ghost-ai-scanner/src/ingestor/ingestor.py
+++ b/ghost-ai-scanner/src/ingestor/ingestor.py
@@ -63,10 +63,11 @@ def run(self) -> dict:
unauthorized = load_unauthorized(self._bucket)
if not unauthorized:
- log.error("Unauthorized list empty — scan aborted")
- return {"outcome": "aborted", "reason": "empty unauthorized list"}
+ log.error("Unauthorized list empty — domain/port matching disabled this cycle")
- # Build pipeline with fresh lists
+ # Build pipeline with fresh lists (pipeline handles empty unauthorized gracefully:
+ # ENDPOINT_SCAN events are pre-classified and bypass the matcher entirely,
+ # so endpoint inventory continues even when the unauthorized list is missing)
pipeline = Pipeline(
store=self._store,
authorized=authorized,
diff --git a/ghost-ai-scanner/src/store/agent_store.py b/ghost-ai-scanner/src/store/agent_store.py
index dae0349..355deb6 100644
--- a/ghost-ai-scanner/src/store/agent_store.py
+++ b/ghost-ai-scanner/src/store/agent_store.py
@@ -29,6 +29,7 @@
import logging
import os
import secrets
+import threading
import time
import uuid
from datetime import datetime, timezone, timedelta
@@ -45,6 +46,10 @@
PRESIGN_TTL = 172800 # 48 hours — installer + meta delivery
HEARTBEAT_PRESIGN_TTL = 604800 # 7 days — max AWS IAM presigned PUT TTL
+# Process-wide lock for catalog read-modify-write. Prevents lost updates
+# when two package generations run concurrently (e.g., two Streamlit sessions).
+_catalog_lock = threading.Lock()
+
class AgentStore(BaseStore):
"""Manages OTP-locked agent installer packages on S3."""
@@ -260,20 +265,21 @@ def _catalog_add(
os_type: str,
created_at: str,
) -> None:
- """Append a new entry to catalog.json on S3."""
- catalog = self.list_catalog()
- catalog.append({
- "token": token,
- "recipient_name": recipient_name,
- "recipient_email": recipient_email,
- "os_type": os_type,
- "created_at": created_at,
- "status": "pending",
- })
- try:
- self._put(CATALOG_KEY, json.dumps(catalog, indent=2).encode(), "application/json")
- except Exception as e:
- log.error("_catalog_add write failed: %s", e)
+ """Append a new entry to catalog.json on S3 under a process-wide lock."""
+ with _catalog_lock:
+ catalog = self.list_catalog()
+ catalog.append({
+ "token": token,
+ "recipient_name": recipient_name,
+ "recipient_email": recipient_email,
+ "os_type": os_type,
+ "created_at": created_at,
+ "status": "pending",
+ })
+ try:
+ self._put(CATALOG_KEY, json.dumps(catalog, indent=2).encode(), "application/json")
+ except Exception as e:
+ log.error("_catalog_add write failed: %s", e)
def delete_package(self, token: str, os_type: str = "") -> bool:
"""
@@ -283,6 +289,7 @@ def delete_package(self, token: str, os_type: str = "") -> bool:
"""
prefix = f"{HOOK_AGENTS_PREFIX}/{token}/"
try:
+ total_deleted = 0
paginator = self.s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
objects = [{"Key": o["Key"]} for o in page.get("Contents", [])]
@@ -291,10 +298,12 @@ def delete_package(self, token: str, os_type: str = "") -> bool:
Bucket=self.bucket,
Delete={"Objects": objects, "Quiet": True},
)
- catalog = [e for e in self.list_catalog() if e["token"] != token]
- self._put(CATALOG_KEY, json.dumps(catalog, indent=2).encode(),
- "application/json")
- log.info("delete_package: purged prefix %s (%d objects)", prefix, len(objects) if 'objects' in dir() else 0)
+ total_deleted += len(objects)
+ with _catalog_lock:
+ catalog = [e for e in self.list_catalog() if e["token"] != token]
+ self._put(CATALOG_KEY, json.dumps(catalog, indent=2).encode(),
+ "application/json")
+ log.info("delete_package: purged prefix %s (%d objects)", prefix, total_deleted)
return True
except Exception as e:
log.error("delete_package failed [%s]: %s", token, e)
diff --git a/ghost-ai-scanner/tests/unit/test_agents_workflows_scan.py b/ghost-ai-scanner/tests/unit/test_agents_workflows_scan.py
index d819d84..6db39a4 100644
--- a/ghost-ai-scanner/tests/unit/test_agents_workflows_scan.py
+++ b/ghost-ai-scanner/tests/unit/test_agents_workflows_scan.py
@@ -92,7 +92,7 @@ def test_non_workflow_files_are_skipped(tmp_path):
def test_workflow_filename_capped_at_120_chars(tmp_path):
n8n_dir = tmp_path / ".n8n" / "workflows"
n8n_dir.mkdir(parents=True)
- long_name = ("x" * 200) + ".json"
+ long_name = ("x" * 130) + ".json" # 135 chars — exceeds 120 cap but fits Windows MAX_PATH
(n8n_dir / long_name).write_text("{}")
out = _run_workflows_scan(tmp_path)
f = next(x for x in out if x["type"] == "agent_workflow")
diff --git a/ghost-ai-scanner/tests/unit/test_docs_index.py b/ghost-ai-scanner/tests/unit/test_docs_index.py
index 5e9d7c7..bbb0c9b 100644
--- a/ghost-ai-scanner/tests/unit/test_docs_index.py
+++ b/ghost-ai-scanner/tests/unit/test_docs_index.py
@@ -115,13 +115,15 @@ def synthetic_index(tmp_path, monkeypatch):
"# PatronAI Linux Agent\n\n"
"To install the Linux agent run setup_agent.sh.\n\n"
"To uninstall the Linux agent: rm -rf ~/.patronai && "
- "crontab -r\n\nUninstall removes everything cleanly."
+ "crontab -r\n\nUninstall removes everything cleanly.",
+ encoding="utf-8",
)
(docs / "windows.md").write_text(
"# PatronAI Windows Agent\n\n"
"Windows install uses setup_agent.ps1 in PowerShell.\n\n"
"Windows uninstall: Add or Remove Programs → PatronAI Agent → "
- "Uninstall.\n\nThe scheduled task is also removed."
+ "Uninstall.\n\nThe scheduled task is also removed.",
+ encoding="utf-8",
)
(docs / "mac.html").write_text(
""
@@ -129,7 +131,8 @@ def synthetic_index(tmp_path, monkeypatch):
"To install on Mac, run bash setup_agent.sh.
"
"To uninstall on Mac: rm -rf ~/.patronai/ and remove the launchd "
"plist at ~/Library/LaunchAgents/com.giggso.patronai.plist. "
- "Then launchctl unload that plist file.