From 353152289064c06d36851a42859c7cc3d41766eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 03:26:29 +0000 Subject: [PATCH] feat(safety): owner allowlist + two-tier menu with clearer item names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ownership safety guard so scripts NEVER touch repositories outside a configured allowlist of owners (defaults to ["hyperpolymath"]; edit config/owners.config or set GIT_SCRIPTS_ALLOWED_OWNERS to add personal / family / additional org accounts). The guard is enforced in two parallel implementations that share the same config: - scripts/lib/ownership_guard.sh — sourced by every shell script that targets a single org or pushes to remotes; provides owner_allowed/repo_allowed/assert_owner_allowed and a host-agnostic owner extractor (works for GitHub, GitLab, Bitbucket, Gitea, self-hosted, SSH-style, etc.). - lib/script_manager/ownership_guard.ex — the Elixir equivalent; exposes allowed_owners/0, owner_allowed?/1, repo_allowed?/1, filter_allowed/1, filter_allowed_verbose/1 and assert_owner_allowed!/1. Wired into all the scripts/modules that can mutate or affect repos: shell: branch-protection-apply, wiki-audit, project-tabs-audit, audit_script (per-repo filter + uses derived owner for the Dependabot URL), update_repos (per-repo filter before push), standardize_readmes & md_to_adoc_converter (per-repo filter). elixir: PRProcessor.process_all/add_standard_comment (asserts org), GitSyncer.run (filters discovered repos before push), EstateDeployer.deploy_by_paths (filters before writing files), DependencyFixer.fix_lithoglyph/fix_rgtv (refuses to patch when enclosing repo is foreign-owned), RepoCleanup (warns the external cleanup scripts are NOT bound by the allowlist). Also rewrites the TUI menu as two tiers with clearer item names: [A] Audits & Reports — wiki, project metadata, contractiles, secrets/Dependabot, health dashboard, local-vs-remote sync verification [B] Repository Maintenance — update repos, global git sync, standardise READMEs, MD→AsciiDoc, clean unicode, cleanup ops, dep fixes [C] GitHub Operations — branch protection rulesets, mass PR processor, gh CLI helper [D] Estate-Wide Deployment — deploy estate standards, link toolchains, find media repos [E] External Tools — launch NQC, launch Invariant Path [F] Coming Soon — dependency updater, release manager The startup banner shows the active owner allowlist and the help and system-status screens both surface it so it's obvious at a glance. Note: rebuild the escript with `mix escript.build` to pick up the Elixir-side changes; the bash-side guard is active immediately. https://claude.ai/code/session_014ME3ph3UecQQAPQDKY2HPf --- config/owners.config | 27 ++ lib/script_manager/dependency_fixer.ex | 120 ++++-- lib/script_manager/estate_deployer.ex | 6 +- lib/script_manager/git_syncer.ex | 10 +- lib/script_manager/ownership_guard.ex | 184 +++++++++ lib/script_manager/pr_processor.ex | 11 +- lib/script_manager/repo_cleanup.ex | 7 + lib/script_manager/tui.ex | 550 +++++++++++++++---------- scripts/audit_script.sh | 33 +- scripts/branch-protection-apply.sh | 9 + scripts/lib/ownership_guard.sh | 135 ++++++ scripts/md_to_adoc_converter.sh | 17 +- scripts/project-tabs-audit.sh | 9 + scripts/standardize_readmes.sh | 14 +- scripts/update_repos.sh | 31 +- scripts/wiki-audit.sh | 6 + 16 files changed, 883 insertions(+), 286 deletions(-) create mode 100644 config/owners.config create mode 100644 lib/script_manager/ownership_guard.ex create mode 100644 scripts/lib/ownership_guard.sh diff --git a/config/owners.config b/config/owners.config new file mode 100644 index 0000000..6777998 --- /dev/null +++ b/config/owners.config @@ -0,0 +1,27 @@ +# Git Scripts — Owner Allowlist +# +# These scripts must NEVER touch repositories owned by anyone outside this +# list. The guard refuses to operate (and exits non-zero) if a target repo's +# GitHub owner is not present here. +# +# Add to this list: +# - your own GitHub username +# - any family members you maintain repos for (e.g. your son) +# - any organisations you control +# +# The owner check is case-insensitive. One owner per line in the array. +# +# Override at runtime without editing this file by exporting: +# GIT_SCRIPTS_ALLOWED_OWNERS="ownerA ownerB ownerC" +# (space- or comma-separated). + +ALLOWED_OWNERS=( + "hyperpolymath" +) + +if [[ -n "${GIT_SCRIPTS_ALLOWED_OWNERS:-}" ]]; then + # Replace the array entirely from the env var + IFS=', ' read -r -a ALLOWED_OWNERS <<< "${GIT_SCRIPTS_ALLOWED_OWNERS}" +fi + +export ALLOWED_OWNERS diff --git a/lib/script_manager/dependency_fixer.ex b/lib/script_manager/dependency_fixer.ex index b4ae215..797dcc4 100644 --- a/lib/script_manager/dependency_fixer.ex +++ b/lib/script_manager/dependency_fixer.ex @@ -11,37 +11,66 @@ defmodule ScriptManager.DependencyFixer do def run do IO.puts("\n🔧 DEPENDENCY FIXER (Hardened Mode)") IO.puts("===================================") - + fix_lithoglyph() fix_rgtv() - + IO.puts("\n✅ Dependency fixing complete!") :ok end + # Walk up to the enclosing git working tree and check the owner allowlist. + # Returns true if the directory has no enclosing repo (we can't tell, so allow + # the explicit per-path edits to proceed inside our own filesystem layout). + @spec safe_to_edit?(String.t()) :: boolean() + defp safe_to_edit?(path) do + case System.cmd("git", ["-C", path, "rev-parse", "--show-toplevel"], stderr_to_stdout: true) do + {toplevel, 0} -> + ScriptManager.OwnershipGuard.repo_allowed?(String.trim(toplevel)) + + _ -> + # Not inside a git repo: nothing remote to push to, no owner to violate. + true + end + end + @spec fix_lithoglyph() :: :ok defp fix_lithoglyph do path = "/var/mnt/eclipse/repos/nextgen-databases/lithoglyph/core-zig" IO.puts("Fixing Lithoglyph in #{path}...") - + try do - if File.dir?(path) do - build_zig = Path.join(path, "build.zig") - if File.exists?(build_zig) do - content = File.read!(build_zig) - new_content = String.replace(content, "const crypto_tests = b.addTest(.{", "const _crypto_tests = b.addTest(.{") - File.write!(build_zig, new_content) - IO.puts(" ✓ build.zig patched") - - IO.puts(" Running tests...") - System.cmd("zig", ["build", "test"], cd: path, into: IO.stream(:stdio, :line)) - end - else - IO.puts(" ⚠ Lithoglyph directory not found") + cond do + not File.dir?(path) -> + IO.puts(" ⚠ Lithoglyph directory not found") + + not safe_to_edit?(path) -> + IO.puts(" 🛡 Skipping: enclosing repo is outside the owner allowlist.") + + true -> + do_fix_lithoglyph(path) end rescue e -> IO.puts(" ❌ Failed to fix Lithoglyph: #{inspect(e)}") end + + :ok + end + + @spec do_fix_lithoglyph(String.t()) :: :ok + defp do_fix_lithoglyph(path) do + build_zig = Path.join(path, "build.zig") + + if File.exists?(build_zig) do + content = File.read!(build_zig) + new_content = String.replace(content, "const crypto_tests = b.addTest(.{", "const _crypto_tests = b.addTest(.{") + File.write!(build_zig, new_content) + IO.puts(" ✓ build.zig patched") + + IO.puts(" Running tests...") + System.cmd("zig", ["build", "test"], cd: path, into: IO.stream(:stdio, :line)) + end + :ok end @@ -49,33 +78,48 @@ defmodule ScriptManager.DependencyFixer do defp fix_rgtv do path = "/var/mnt/eclipse/repos/reasonably-good-token-vault/vault-core" IO.puts("Fixing RGTV in #{path}...") - + try do - if File.dir?(path) do - primes_rs = Path.join([path, "src", "primes.rs"]) - if File.exists?(primes_rs) do - content = File.read!(primes_rs) - new_content = String.replace(content, "use num_bigint::{BigUint, RandBigInt, ToBigUint};", "use num_bigint::{BigUint, ToBigUint};") - File.write!(primes_rs, new_content) - IO.puts(" ✓ src/primes.rs patched") - end - - crypto_rs = Path.join([path, "src", "crypto.rs"]) - if File.exists?(crypto_rs) do - content = File.read!(crypto_rs) - new_content = String.replace(content, "use ed448_goldilocks::EdwardsPoint::generator()", "use ed448_goldilocks::edwards::EdwardsPoint::generator()") - File.write!(crypto_rs, new_content) - IO.puts(" ✓ src/crypto.rs patched") - end - - IO.puts(" Running tests...") - System.cmd("cargo", ["test", "--lib"], cd: path, into: IO.stream(:stdio, :line)) - else - IO.puts(" ⚠ RGTV directory not found") + cond do + not File.dir?(path) -> + IO.puts(" ⚠ RGTV directory not found") + + not safe_to_edit?(path) -> + IO.puts(" 🛡 Skipping: enclosing repo is outside the owner allowlist.") + + true -> + do_fix_rgtv(path) end rescue e -> IO.puts(" ❌ Failed to fix RGTV: #{inspect(e)}") end + + :ok + end + + @spec do_fix_rgtv(String.t()) :: :ok + defp do_fix_rgtv(path) do + primes_rs = Path.join([path, "src", "primes.rs"]) + + if File.exists?(primes_rs) do + content = File.read!(primes_rs) + new_content = String.replace(content, "use num_bigint::{BigUint, RandBigInt, ToBigUint};", "use num_bigint::{BigUint, ToBigUint};") + File.write!(primes_rs, new_content) + IO.puts(" ✓ src/primes.rs patched") + end + + crypto_rs = Path.join([path, "src", "crypto.rs"]) + + if File.exists?(crypto_rs) do + content = File.read!(crypto_rs) + new_content = String.replace(content, "use ed448_goldilocks::EdwardsPoint::generator()", "use ed448_goldilocks::edwards::EdwardsPoint::generator()") + File.write!(crypto_rs, new_content) + IO.puts(" ✓ src/crypto.rs patched") + end + + IO.puts(" Running tests...") + System.cmd("cargo", ["test", "--lib"], cd: path, into: IO.stream(:stdio, :line)) + :ok end end diff --git a/lib/script_manager/estate_deployer.ex b/lib/script_manager/estate_deployer.ex index 24bd83b..88024ef 100644 --- a/lib/script_manager/estate_deployer.ex +++ b/lib/script_manager/estate_deployer.ex @@ -2,6 +2,7 @@ defmodule ScriptManager.EstateDeployer do @moduledoc "Estate deployment logic generalized for all repositories" alias ScriptManager.RepoHelper + alias ScriptManager.OwnershipGuard @contractile_types ["must", "trust", "dust", "lust", "adjust", "intend"] @standards_dir "/var/mnt/eclipse/repos/standards" @@ -65,9 +66,12 @@ defmodule ScriptManager.EstateDeployer do end defp deploy_by_paths(repo_paths, phases) do + # Ownership guard: refuse to deploy into repos outside the allowlist. + repo_paths = OwnershipGuard.filter_allowed_verbose(repo_paths) + total = length(repo_paths) IO.puts("Processing #{total} repositories...") - + repo_paths |> Enum.with_index(1) |> Enum.each(fn {path, index} -> diff --git a/lib/script_manager/git_syncer.ex b/lib/script_manager/git_syncer.ex index 96b9b5b..9c6f357 100644 --- a/lib/script_manager/git_syncer.ex +++ b/lib/script_manager/git_syncer.ex @@ -5,6 +5,7 @@ defmodule ScriptManager.GitSyncer do """ alias ScriptManager.RepoHelper + alias ScriptManager.OwnershipGuard @type sync_status :: String.t() @type merge_status :: String.t() @@ -16,9 +17,12 @@ defmodule ScriptManager.GitSyncer do def run do IO.puts("\n🌐 GLOBAL GIT SYNC (Concurrent Strict Mode)") IO.puts("============================================") - - all_repos = RepoHelper.find_all_repos() - + + # Ownership guard: never push to repos outside the allowlist. + all_repos = + RepoHelper.find_all_repos() + |> OwnershipGuard.filter_allowed_verbose() + header = "| Repository | Sync Status | Merge Status | Push Status |" separator = "| :--- | :--- | :--- | :--- |" diff --git a/lib/script_manager/ownership_guard.ex b/lib/script_manager/ownership_guard.ex new file mode 100644 index 0000000..ac53384 --- /dev/null +++ b/lib/script_manager/ownership_guard.ex @@ -0,0 +1,184 @@ +defmodule ScriptManager.OwnershipGuard do + @moduledoc """ + Owner allowlist gate for any script that talks to GitHub or pushes to remotes. + + Refuses to act on repositories owned by anyone outside the configured + allowlist. Mirrors `scripts/lib/ownership_guard.sh` so bash scripts and + Elixir modules behave the same way. + + Allowlist sources (first non-empty wins): + 1. `GIT_SCRIPTS_ALLOWED_OWNERS` env var (space- or comma-separated). + 2. `config/owners.config` (parsed for the `ALLOWED_OWNERS=( … )` array). + 3. The default `["hyperpolymath"]`. + """ + + @config_paths [ + "config/owners.config", + "/var/mnt/eclipse/repos/git-scripts/config/owners.config" + ] + + @default_owners ["hyperpolymath"] + + @doc "Return the configured list of allowed owners (lowercase)." + @spec allowed_owners() :: [String.t()] + def allowed_owners do + raw = + case System.get_env("GIT_SCRIPTS_ALLOWED_OWNERS") do + nil -> from_config_file() || @default_owners + "" -> from_config_file() || @default_owners + env -> parse_list(env) + end + + Enum.map(raw, &String.downcase/1) + end + + @doc "True when an owner string is in the allowlist." + @spec owner_allowed?(String.t() | nil) :: boolean() + def owner_allowed?(nil), do: false + def owner_allowed?(""), do: false + def owner_allowed?(owner) when is_binary(owner) do + String.downcase(owner) in allowed_owners() + end + + @doc "Get the GitHub owner from a local repo's `origin` remote, or nil." + @spec repo_owner(String.t()) :: String.t() | nil + def repo_owner(repo_path) do + case System.cmd("git", ["-C", repo_path, "config", "--get", "remote.origin.url"], + stderr_to_stdout: true + ) do + {url, 0} -> url |> String.trim() |> parse_owner_from_url() + _ -> nil + end + rescue + _ -> nil + end + + @doc "True when the local repo's GitHub owner is in the allowlist." + @spec repo_allowed?(String.t()) :: boolean() + def repo_allowed?(repo_path) do + case repo_owner(repo_path) do + nil -> false + owner -> owner_allowed?(owner) + end + end + + @doc """ + Filter a list of repo paths down to those whose origin owner is allowed. + Repos with no GitHub origin are excluded. + """ + @spec filter_allowed([String.t()]) :: [String.t()] + def filter_allowed(paths) when is_list(paths) do + Enum.filter(paths, &repo_allowed?/1) + end + + @doc """ + Like `filter_allowed/1` but also prints a one-line summary of how many + repos were rejected, so the user can see the guard at work. + """ + @spec filter_allowed_verbose([String.t()]) :: [String.t()] + def filter_allowed_verbose(paths) when is_list(paths) do + {allowed, rejected} = Enum.split_with(paths, &repo_allowed?/1) + + if rejected != [] do + IO.puts( + "🛡 Ownership guard: skipping #{length(rejected)} repo(s) outside allowlist " <> + "(#{Enum.join(allowed_owners(), ", ")})." + ) + end + + allowed + end + + @doc """ + Hard guard. Aborts the running script with a clear message if `owner` + is not in the allowlist. Use at the top of any operation that targets + a single org/user (e.g. mass PR labelling). + """ + @spec assert_owner_allowed!(String.t()) :: :ok | no_return() + def assert_owner_allowed!(owner) do + if owner_allowed?(owner) do + :ok + else + IO.puts(:stderr, """ + + ❌ REFUSING to operate on owner '#{owner}'. + This owner is not in the git-scripts allowlist. + Allowed owners: #{Enum.join(allowed_owners(), ", ")} + + Edit config/owners.config or set GIT_SCRIPTS_ALLOWED_OWNERS=\"owner1 owner2\" + to change this. + """) + + System.halt(78) + end + end + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + # Host-agnostic owner parser. Handles SSH-style (git@host:path) and URL-style + # (proto://[creds@]host[:port]/path) and treats the second-to-last path + # segment as the owner — works for GitHub, GitLab, Bitbucket, Gitea, + # codeberg, self-hosted servers, and so on. + defp parse_owner_from_url(url) do + url = String.trim_trailing(url, ".git") + + path_part = + cond do + # SSH-style: [user@]host:path + m = Regex.run(~r{^[^/\s@]+@[^:]+:(.+)$}, url) -> Enum.at(m, 1) + # URL-style: proto://[creds@]host[:port]/path + m = Regex.run(~r{^[a-zA-Z]+://[^/]+(/.+)$}, url) -> Enum.at(m, 1) + true -> nil + end + + case path_part do + nil -> nil + "" -> nil + pp -> + pp = pp |> String.trim_leading("/") |> String.trim_trailing("/") + case String.split(pp, "/") do + segments when length(segments) >= 2 -> Enum.at(segments, length(segments) - 2) + _ -> nil + end + end + end + + defp parse_list(str) do + str + |> String.split([",", " ", "\n", "\t"], trim: true) + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + + defp from_config_file do + @config_paths + |> Enum.find_value(fn path -> + if File.exists?(path), do: parse_config_file(path), else: nil + end) + end + + defp parse_config_file(path) do + case File.read(path) do + {:ok, contents} -> + case Regex.run(~r/ALLOWED_OWNERS=\(([^)]*)\)/s, contents) do + [_, body] -> + body + |> String.split([",", " ", "\n", "\t"], trim: true) + |> Enum.map(&String.trim(&1, "\"")) + |> Enum.map(&String.trim(&1, "'")) + |> Enum.reject(&(&1 == "")) + |> Enum.reject(&String.starts_with?(&1, "#")) + |> case do + [] -> nil + owners -> owners + end + + _ -> nil + end + + _ -> nil + end + end +end diff --git a/lib/script_manager/pr_processor.ex b/lib/script_manager/pr_processor.ex index 4f428a0..d653eb6 100644 --- a/lib/script_manager/pr_processor.ex +++ b/lib/script_manager/pr_processor.ex @@ -9,8 +9,12 @@ defmodule ScriptManager.PRProcessor do def process_all(org, action) when action in [:add_reviewers, :request_changes, :add_labels, :add_comments, :request_reviews, :close_stale] do IO.puts("\n🔄 MASS PR PROCESSOR") IO.puts("====================") + + # Ownership guard: refuse to run against orgs/users outside the allowlist. + ScriptManager.OwnershipGuard.assert_owner_allowed!(org) + IO.puts("Processing all open PRs with action: #{action}") - + prs = ScriptManager.GitHubAPI.get_open_prs(org) if length(prs) == 0 do @@ -78,7 +82,10 @@ defmodule ScriptManager.PRProcessor do def add_standard_comment(org, comment) do IO.puts("\n📝 ADD STANDARD COMMENT") IO.puts("======================") - + + # Ownership guard: refuse to comment on PRs in orgs outside the allowlist. + ScriptManager.OwnershipGuard.assert_owner_allowed!(org) + prs = ScriptManager.GitHubAPI.get_open_prs(org) Enum.each(prs, fn pr -> diff --git a/lib/script_manager/repo_cleanup.ex b/lib/script_manager/repo_cleanup.ex index 0a4a96f..d6cfacf 100644 --- a/lib/script_manager/repo_cleanup.ex +++ b/lib/script_manager/repo_cleanup.ex @@ -6,6 +6,13 @@ defmodule ScriptManager.RepoCleanup do IO.puts("🧹 REPOSITORY CLEANUP") IO.puts("====================") IO.puts("") + IO.puts( + "⚠️ These cleanup operations shell out to external scripts in" <> + " /var/mnt/eclipse/cleanup_scripts/ which iterate every local repo and" <> + " are NOT bound by config/owners.config. Review those scripts before" <> + " running them." + ) + IO.puts("") IO.puts("Select cleanup operation:") IO.puts("[1] Run comprehensive cleanup (all 280+ repos)") IO.puts("[2] Run targeted cleanup (10 key repos)") diff --git a/lib/script_manager/tui.ex b/lib/script_manager/tui.ex index 73b0cef..ccaecb0 100644 --- a/lib/script_manager/tui.ex +++ b/lib/script_manager/tui.ex @@ -1,59 +1,172 @@ defmodule ScriptManager.TUI do @moduledoc """ - Enhanced Elixir TUI for managing reusable scripts and functions - + Two-tier TUI for managing reusable scripts and functions. + + Top level groups related operations into categories ([A]–[F]); each + category opens a sub-menu of numbered items. Item names have been + rewritten to describe what they actually do at a glance. + Features: - Self-healing: Automatic recovery from common errors - Fault tolerant: Graceful handling of failures - Self-diagnostic: Pre-execution validation - Help system: Detailed information for each function - User negotiation: Confirmations for critical operations + - Ownership guard: Refuses to operate on repos outside the allowlist """ + # ---------------------------------------------------------------------- + # Menu definition (single source of truth) + # ---------------------------------------------------------------------- + + # Each category has a key (letter), a title, and a list of items. + # Each item has: a sub-key (number), a display name, the action, and an + # optional one-line help blurb. + # + # Action shapes: + # {:fun, fun} — call fun.() + # {:fun, fun, args} — apply(fun, args) + # {:fun_confirm, fun, prompt} — confirm, then call fun.() + # {:nyi, message} — print "coming soon" message + defp categories do + [ + {"A", "Audits & Reports", + [ + {"1", "Audit Wiki Status", + {:fun, &ScriptManager.WikiAudit.run/0}, + "Audit GitHub wiki status across allowed repos."}, + {"2", "Audit Project Metadata (About)", + {:fun, &ScriptManager.ProjectTabsAudit.run/0}, + "Audit description, homepage URL, and topics on each repo."}, + {"3", "Audit Contractile Implementation", + {:fun, &ScriptManager.ContractileAuditor.run/0}, + "Check must/trust/dust/lust/adjust/intend contractiles + K9-SVC."}, + {"4", "Security Audit (Secrets & Dependabot)", + {:fun, &ScriptManager.ScriptAuditor.run/0}, + "Scan for secrets (gitleaks) and Dependabot Critical/High alerts."}, + {"5", "Repository Health Dashboard", + {:fun, &ScriptManager.HealthDashboard.generate_report/0}, + "Generate a health-score report for repositories."}, + {"6", "Verify Local-vs-Remote Sync", + {:fun, &ScriptManager.Verifier.run/0}, + "Compare each repo's local HEAD message to its remote."} + ]}, + {"B", "Repository Maintenance", + [ + {"1", "Update Repos (Sync, Commit, Push)", + {:fun, &ScriptManager.RepoUpdater.run/0}, + "Pull, rebase, commit, and push the configured repo set."}, + {"2", "Global Git Sync (All Allowed Repos)", + {:fun, &ScriptManager.GitSyncer.run/0}, + "Concurrent sync/merge/push across every allowed local repo."}, + {"3", "Standardize README Format", + {:fun, &ScriptManager.ReadmeStandardizer.run/0}, + "Convert and consolidate README files to README.adoc."}, + {"4", "Convert Markdown to AsciiDoc", + {:fun, &ScriptManager.MDConverter.run/0}, + "Bulk-convert lingering README.md files to AsciiDoc."}, + {"5", "Clean Hidden Unicode in Files", + {:fun, &__MODULE__.run_clean_unicode/0}, + "Strip hidden / bidi Unicode characters from tracked files."}, + {"6", "Repository Cleanup Operations", + {:fun_confirm, &ScriptManager.RepoCleanup.run/0, + "This may delete files. Continue?"}, + "Run gitignore updates, workflow commits, or full cleanup."}, + {"7", "Fix Known Dependency Issues", + {:fun, &ScriptManager.DependencyFixer.run/0}, + "Apply hard-coded patches for Lithoglyph and RGTV builds."} + ]}, + {"C", "GitHub Operations", + [ + {"1", "Apply Branch Protection Rulesets", + {:fun_confirm, &ScriptManager.BranchProtection.run/0, + "This will modify repository settings. Continue?"}, + "Push the standard ruleset (signed commits, linear history…)."}, + {"2", "Mass PR Processor (Labels/Comments)", + {:fun, &ScriptManager.PRProcessor.process_all/2, + ["hyperpolymath", :add_labels]}, + "Apply labels in bulk to open PRs across the allowed org."}, + {"3", "GitHub CLI Helper", + {:fun, &ScriptManager.GHCLI.run/0}, + "Print useful gh commands and verify gh auth status."} + ]}, + {"D", "Estate-Wide Deployment", + [ + {"1", "Deploy Estate Standards", + {:fun, &ScriptManager.EstateDeployer.run/0}, + "Deploy contractiles, K9-SVC, accessibility, VPAT, pre-commit hook."}, + {"2", "Link Language Toolchains", + {:fun, &ScriptManager.ToolchainLinker.run/0}, + "Symlink built compiler/runtime binaries into ~/.local/bin."}, + {"3", "Find Media Repositories (rclone)", + {:fun, &ScriptManager.MediaFinder.run/0}, + "Scan rclone remotes for directories that look like media repos."} + ]}, + {"E", "External Tools", + [ + {"1", "Launch NQC (Database Query)", + {:fun, &__MODULE__.launch_nqc/0}, + "Open the NextGen Query Client web UI for VQL/GQL/KQL."}, + {"2", "Launch Invariant Path (Code Analysis)", + {:fun, &__MODULE__.launch_invariant_path/0}, + "Open the Invariant Path code-analysis tool."} + ]}, + {"F", "Coming Soon", + [ + {"1", "Dependency Updater", + {:nyi, "📦 Dependency Updater - Coming Soon!"}, + "Cross-language dependency upgrade orchestration (planned)."}, + {"2", "Release Manager", + {:nyi, "🎉 Release Manager - Coming Soon!"}, + "Tagging, changelog, and GitHub release automation (planned)."} + ]} + ] + end + + # ---------------------------------------------------------------------- + # Lifecycle + # ---------------------------------------------------------------------- + @doc "Main TUI loop" def run do - # Set up error handling Process.flag(:trap_exit, true) - - # Show welcome banner + show_banner() - - # Check system health check_system_health() - - # Start main menu - menu() + show_owner_allowlist() + + main_loop() rescue - error -> + error -> IO.puts("\n❌ Critical error: #{inspect(error)}") IO.puts("Restarting TUI...") run() end defp show_banner do - IO.puts("\n🔧 ELIXIR SCRIPT MANAGER v2.0") + IO.puts("\n🔧 ELIXIR SCRIPT MANAGER v2.1") IO.puts("==============================") IO.puts("Self-Healing, Fault-Tolerant TUI") + IO.puts("Two-tier menu | Ownership-guarded operations") IO.puts("Type 'h' for help, '0' to exit") IO.puts("") end defp check_system_health do - # Check if required commands are available required_commands = ["bash", "git", "gh"] - + missing = Enum.filter(required_commands, fn cmd -> - System.cmd("which", [cmd], [stderr_to_stdout: true]) |> elem(0) != 0 + System.cmd("which", [cmd], stderr_to_stdout: true) |> elem(0) != 0 end) - + if missing != [] do IO.puts("⚠️ Missing required commands: #{inspect(missing)}") IO.puts("Some functions may not work properly.") IO.puts("") end - - # Check if scripts directory exists + scripts_dir = "scripts" + if !File.exists?(scripts_dir) do IO.puts("⚠️ Scripts directory not found: #{scripts_dir}") IO.puts("Script-based functions will not work.") @@ -61,121 +174,136 @@ defmodule ScriptManager.TUI do end end - defp menu do - loop() + defp show_owner_allowlist do + owners = ScriptManager.OwnershipGuard.allowed_owners() + + IO.puts("🛡 Ownership allowlist: #{Enum.join(owners, ", ")}") + IO.puts(" (Edit config/owners.config to add your son's or other owners.)") + IO.puts("") end - - defp loop do + + # ---------------------------------------------------------------------- + # Top-level menu + # ---------------------------------------------------------------------- + + defp main_loop do IO.puts("\n" <> String.duplicate("=", 50)) IO.puts("MAIN MENU") IO.puts(String.duplicate("=", 50)) - - IO.puts("\n[1] Wiki Audit") - IO.puts("[2] Project Tabs Audit") - IO.puts("[3] Branch Protection Apply") - IO.puts("[4] MD to ADOC Converter") - IO.puts("[5] Standardize READMEs") - IO.puts("[6] Update Repos") - IO.puts("[7] Audit Scripts") - IO.puts("[8] Verify") - IO.puts("[9] Use GH CLI") - IO.puts("[10] Mass PR Processor") - IO.puts("[11] Health Dashboard") - IO.puts("[12] Repository Cleanup") - IO.puts("[13] Clean Unicode") - IO.puts("[14] Dependency Updater") - IO.puts("[15] Release Manager") - IO.puts("[16] Contractile Audit") - IO.puts("[17] Estate Deployer") - IO.puts("[18] Global Git Sync") - IO.puts("[19] Media Finder") - IO.puts("[20] Dependency Fixer") - IO.puts("[21] Toolchain Linker") - IO.puts("[22] Launch NQC (Database Query)") - IO.puts("[23] Launch Invariant Path (Code Analysis)") - IO.puts("\n[h] Help - Detailed information") + + Enum.each(categories(), fn {key, title, items} -> + IO.puts("[#{key}] #{title} (#{length(items)})") + end) + + IO.puts("") + IO.puts("[h] Help - Detailed information") IO.puts("[s] System Status") IO.puts("[0] Exit") - - IO.write("\nSelect option: ") - choice = - try do - input = IO.gets("") - cond do - input == nil -> "0" # Handle EOF as exit - input == :eof -> "0" # Handle EOF as exit - true -> String.trim(input) - end - rescue - _ -> "0" # Any error, default to exit - end - + + IO.write("\nSelect category: ") + choice = read_choice() |> String.upcase() + case choice do - "1" -> safe_execute(&ScriptManager.WikiAudit.run/0, "Wiki Audit") - "2" -> safe_execute(&ScriptManager.ProjectTabsAudit.run/0, "Project Tabs Audit") - "3" -> safe_execute_with_confirm(&ScriptManager.BranchProtection.run/0, "Branch Protection Apply", "This will modify repository settings. Continue?") - "4" -> safe_execute(&ScriptManager.MDConverter.run/0, "MD to ADOC Converter") - "5" -> safe_execute(&ScriptManager.ReadmeStandardizer.run/0, "Standardize READMEs") - "6" -> safe_execute(&ScriptManager.RepoUpdater.run/0, "Update Repos") - "7" -> safe_execute(&ScriptManager.ScriptAuditor.run/0, "Audit Scripts") - "8" -> safe_execute(&ScriptManager.Verifier.run/0, "Verify") - "9" -> safe_execute(&ScriptManager.GHCLI.run/0, "GH CLI") - "10" -> safe_execute(&ScriptManager.PRProcessor.process_all/2, "Mass PR Processor", ["hyperpolymath", :add_labels]) - "11" -> safe_execute(&ScriptManager.HealthDashboard.generate_report/0, "Health Dashboard") - "12" -> safe_execute_with_confirm(&ScriptManager.RepoCleanup.run/0, "Repository Cleanup", "This may delete files. Continue?") - "13" -> safe_execute(&run_clean_unicode/0, "Clean Unicode") - "14" -> IO.puts("\n📦 Dependency Updater - Coming Soon!") - "15" -> IO.puts("\n🎉 Release Manager - Coming Soon!") - "16" -> safe_execute(&ScriptManager.ContractileAuditor.run/0, "Contractile Audit") - "17" -> safe_execute(&ScriptManager.EstateDeployer.run/0, "Estate Deployer") - "18" -> safe_execute(&ScriptManager.GitSyncer.run/0, "Global Git Sync") - "19" -> safe_execute(&ScriptManager.MediaFinder.run/0, "Media Finder") - "20" -> safe_execute(&ScriptManager.DependencyFixer.run/0, "Dependency Fixer") - "21" -> safe_execute(&ScriptManager.ToolchainLinker.run/0, "Toolchain Linker") - "22" -> launch_nqc() - "23" -> launch_invariant_path() - "h" -> show_help() - "s" -> show_system_status() - "0" -> IO.puts("\n👋 Goodbye!") - _ -> - IO.puts("\n❌ Invalid choice, please try again") - loop() - end - - # Continue loop unless exiting - if choice != "0" do - loop() + "H" -> + show_help() + main_loop() + + "S" -> + show_system_status() + main_loop() + + "0" -> + IO.puts("\n👋 Goodbye!") + + key -> + case Enum.find(categories(), fn {k, _, _} -> k == key end) do + nil -> + IO.puts("\n❌ Invalid choice, please try again") + main_loop() + + category -> + sub_loop(category) + main_loop() + end end end - defp safe_execute(func, name) do - safe_execute(func, name, []) + # ---------------------------------------------------------------------- + # Sub-menu (per category) + # ---------------------------------------------------------------------- + + defp sub_loop({key, title, items}) do + IO.puts("\n" <> String.duplicate("-", 50)) + IO.puts("[#{key}] #{title}") + IO.puts(String.duplicate("-", 50)) + + Enum.each(items, fn {num, name, _action, _help} -> + IO.puts(" [#{num}] #{name}") + end) + + IO.puts("") + IO.puts(" [b] Back to main menu") + IO.puts(" [0] Exit") + + IO.write("\nSelect item: ") + choice = read_choice() |> String.downcase() + + case choice do + "b" -> + :ok + + "0" -> + IO.puts("\n👋 Goodbye!") + System.halt(0) + + num -> + case Enum.find(items, fn {n, _, _, _} -> n == num end) do + nil -> + IO.puts("\n❌ Invalid choice, please try again") + sub_loop({key, title, items}) + + {_, name, action, _help} -> + invoke(action, name) + sub_loop({key, title, items}) + end + end end + # ---------------------------------------------------------------------- + # Action dispatch + # ---------------------------------------------------------------------- + + defp invoke({:fun, fun}, name), do: safe_execute(fun, name, []) + defp invoke({:fun, fun, args}, name), do: safe_execute(fun, name, args) + + defp invoke({:fun_confirm, fun, prompt}, name), + do: safe_execute_with_confirm(fun, name, prompt) + + defp invoke({:nyi, message}, _name), do: IO.puts("\n" <> message) + defp safe_execute(func, name, args) do IO.puts("\n🔄 Starting: #{name}") IO.puts("=" <> String.duplicate("=", String.length(name) + 1)) - + try do start_time = System.system_time(:millisecond) result = apply(func, args) end_time = System.system_time(:millisecond) - + elapsed_ms = end_time - start_time elapsed_s = elapsed_ms / 1000.0 - + IO.puts("\n✅ #{name} completed in #{Float.round(elapsed_s, 2)} seconds") result rescue error in [FunctionClauseError, UndefinedFunctionError] -> IO.puts("\n❌ Function not available: #{inspect(error)}") IO.puts("This feature may not be implemented yet.") - + error -> IO.puts("\n❌ Error in #{name}: #{inspect(error)}") IO.puts("Attempting recovery...") - - # Try to recover by reloading modules + try do Code.ensure_loaded?(ScriptManager.TUI) IO.puts("✅ Recovered successfully") @@ -189,70 +317,110 @@ defmodule ScriptManager.TUI do IO.puts("\n⚠️ #{name}") IO.puts("This operation may make changes to your repositories.") IO.write("\n#{confirm_msg} (y/N): ") - + response = String.trim(IO.gets("") || "n") - + if String.downcase(response) == "y" do - safe_execute(func, name) + safe_execute(func, name, []) else IO.puts("❌ Operation cancelled by user") end end + # ---------------------------------------------------------------------- + # System status / help + # ---------------------------------------------------------------------- + defp show_system_status do IO.puts("\n" <> String.duplicate("=", 60)) IO.puts("SYSTEM STATUS") IO.puts(String.duplicate("=", 60)) - - # Check required commands + required_commands = ["bash", "git", "gh", "jq"] - + IO.puts("\nRequired Commands:") + Enum.each(required_commands, fn cmd -> - if System.cmd("which", [cmd], [stderr_to_stdout: true]) |> elem(0) == 0 do + if System.cmd("which", [cmd], stderr_to_stdout: true) |> elem(0) == 0 do IO.puts(" ✅ #{cmd}") else IO.puts(" ❌ #{cmd} (missing)") end end) - - # Check scripts directory + scripts_dir = "scripts" + if File.exists?(scripts_dir) do script_count = File.ls!(scripts_dir) |> Enum.count() - IO.puts("\nScripts Directory: ✅ #{script_count} scripts found") + IO.puts("\nScripts Directory: ✅ #{script_count} entries found") else IO.puts("\nScripts Directory: ❌ Not found") end - - # Check GitHub CLI authentication + IO.puts("\nGitHub CLI Status:") - case System.cmd("gh", ["auth", "status"], [stderr_to_stdout: true]) do + + case System.cmd("gh", ["auth", "status"], stderr_to_stdout: true) do {0, output} -> IO.puts(" ✅ Authenticated: #{String.trim(output)}") {_, _} -> IO.puts(" ❌ Not authenticated or error") end - + + IO.puts("\nOwnership Allowlist:") + Enum.each(ScriptManager.OwnershipGuard.allowed_owners(), fn o -> + IO.puts(" • #{o}") + end) + IO.puts("\n" <> String.duplicate("-", 60)) IO.puts("Press Enter to return to main menu...") IO.gets("") end - defp run_clean_unicode do + defp show_help do + IO.puts("\n" <> String.duplicate("=", 60)) + IO.puts("HELP — Detailed Function Information") + IO.puts(String.duplicate("=", 60)) + + Enum.each(categories(), fn {key, title, items} -> + IO.puts("\n[#{key}] #{title}") + IO.puts(String.duplicate("-", 60)) + + Enum.each(items, fn {num, name, _action, blurb} -> + IO.puts(" [#{key}#{num}] #{name}") + if blurb && blurb != "", do: IO.puts(" #{blurb}") + end) + end) + + IO.puts("\n" <> String.duplicate("-", 60)) + IO.puts("Ownership safety:") + IO.puts( + " Operations that touch repositories check the allowlist in" <> + " config/owners.config (or the GIT_SCRIPTS_ALLOWED_OWNERS env var)." + ) + IO.puts(" Add yourself, your son, and any organisations you control there.") + + IO.puts("\nPress Enter to return to main menu...") + IO.gets("") + end + + # ---------------------------------------------------------------------- + # External launchers (kept public so the menu definition can reference them) + # ---------------------------------------------------------------------- + + @doc false + def run_clean_unicode do IO.puts("\n🧼 CLEAN UNICODE") IO.puts("Cleaning hidden/bidirectional Unicode characters from files...") - - # Check if script exists + script_path = "/var/mnt/eclipse/scripts/clean-unicode.sh" - + if File.exists?(script_path) do IO.puts("Running: #{script_path}") - - # Execute with error handling + case System.cmd(script_path, []) do - {0, output} -> + {output, 0} -> IO.puts("✅ Unicode cleaning complete!") IO.puts(output) - {status, error} -> + + {error, status} -> IO.puts("❌ Unicode cleaning failed (exit #{status}):") IO.puts(error) end @@ -260,20 +428,21 @@ defmodule ScriptManager.TUI do IO.puts("❌ Script not found: #{script_path}") IO.puts("Cannot perform Unicode cleaning") end - + :ok end - defp launch_nqc do + @doc false + def launch_nqc do IO.puts("\n🚀 Launching NextGen Query Client...") - nqc_launcher = "/var/mnt/eclipse/repos/nextgen-databases/nqc/nqc-enhanced-launcher.sh" - + if File.exists?(nqc_launcher) do IO.puts("Starting NQC web interface...") + case System.cmd(nqc_launcher, ["--auto"]) do - {0, _} -> IO.puts("✅ NQC launched successfully") - {status, error} -> IO.puts("❌ Failed to launch NQC (exit #{status}): #{error}") + {_, 0} -> IO.puts("✅ NQC launched successfully") + {error, status} -> IO.puts("❌ Failed to launch NQC (exit #{status}): #{error}") end else IO.puts("❌ NQC launcher not found: #{nqc_launcher}") @@ -281,16 +450,18 @@ defmodule ScriptManager.TUI do end end - defp launch_invariant_path do + @doc false + def launch_invariant_path do IO.puts("\n🔍 Launching Invariant Path...") - ip_launcher = "/var/mnt/eclipse/repos/invariant-path/invariant-path-launcher" - + if File.exists?(ip_launcher) do IO.puts("Starting Invariant Path analysis tool...") + case System.cmd(ip_launcher, ["--auto"]) do - {0, _} -> IO.puts("✅ Invariant Path launched successfully") - {status, error} -> IO.puts("❌ Failed to launch Invariant Path (exit #{status}): #{error}") + {_, 0} -> IO.puts("✅ Invariant Path launched successfully") + {error, status} -> + IO.puts("❌ Failed to launch Invariant Path (exit #{status}): #{error}") end else IO.puts("❌ Invariant Path launcher not found: #{ip_launcher}") @@ -298,96 +469,19 @@ defmodule ScriptManager.TUI do end end - defp show_help do - IO.puts("\n" <> String.duplicate("=", 60)) - IO.puts("HELP SYSTEM - Detailed Function Information") - IO.puts(String.duplicate("=", 60)) - - help_items = Map.merge(%{ - "1" => { - "Wiki Audit", - "Audits GitHub wiki status across repositories", - "Checks wiki enabled/disabled, content status, page count", - "Use to identify repos needing wiki setup or cleanup" - }, - "2" => { - "Project Tabs Audit", - "Audits repository project tabs/configuration", - "Checks for proper tab setup and configuration", - "Use to ensure consistent project organization" - }, - "3" => { - "Branch Protection Apply", - "Applies strict branch protection rules to repositories", - "Enforces signed commits, linear history, blocks force pushes", - "WARNING: Modifies repository settings - use with caution" - }, - "4" => { - "MD to ADOC Converter", - "Converts Markdown files to AsciiDoc format", - "Preserves formatting and metadata", - "Use for documentation standardization" - }, - "5" => { - "Standardize READMEs", - "Applies consistent README formatting across repositories", - "Ensures proper structure and content", - "Use to maintain documentation standards" - }, - "6" => { - "Update Repos", - "Updates all repositories to latest versions", - "Pulls latest changes and updates dependencies", - "Use to keep repositories synchronized" - }, - "7" => { - "Audit Scripts", - "Audits the script collection for issues", - "Checks for syntax errors, best practices, security issues", - "Use to maintain script quality" - }, - "8" => { - "Verify", - "Verifies system and repository health", - "Checks for common issues and configuration problems", - "Use for troubleshooting and maintenance" - }, - "9" => { - "Use GH CLI", - "GitHub CLI helper functions", - "Provides convenient GitHub operations", - "Use for GitHub repository management" - }, - "10" => { - "Mass PR Processor", - "Processes pull requests across repositories", - "Can add labels, review, or perform other batch operations", - "Use for bulk PR management" - }}, - %{ - "22" => { - "Launch NQC", - "Launches NextGen Query Client for database operations", - "Provides web interface for VQL/GQL/KQL queries", - "Use for database exploration and querying" - }, - "23" => { - "Launch Invariant Path", - "Launches Invariant Path for code analysis", - "Analyzes claim transitions and invariant preservation", - "Use for code documentation and invariant checking" - } - }) - - Enum.each(help_items, fn {num, {name, desc, details, usage}} -> - IO.puts("\n[#{num}] #{name}") - IO.puts(" Description: #{desc}") - IO.puts(" Details: #{details}") - IO.puts(" Usage: #{usage}") - end) - - IO.puts("\n" <> String.duplicate("-", 60)) - IO.puts("Press Enter to return to main menu...") - IO.gets("") + # ---------------------------------------------------------------------- + # Input helper + # ---------------------------------------------------------------------- + + defp read_choice do + try do + case IO.gets("") do + nil -> "0" + :eof -> "0" + input -> String.trim(input) + end + rescue + _ -> "0" + end end -end \ No newline at end of file +end diff --git a/scripts/audit_script.sh b/scripts/audit_script.sh index 1d81579..8b58dec 100755 --- a/scripts/audit_script.sh +++ b/scripts/audit_script.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -uo pipefail TOKEN="${GITHUB_TOKEN:-}" if [ -z "$TOKEN" ]; then echo "GITHUB_TOKEN is required" >&2 @@ -8,33 +9,47 @@ REPOS_DIR="${REPOS_DIR:-/var/mnt/eclipse/repos}" CONFIG_FILE="$REPOS_DIR/gitleaks_config.toml" GLOBAL_IGNORE="$REPOS_DIR/global_gitleaksignore" +# --- Ownership safety guard --- +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" + # Create global ignore from boj-server cp "$REPOS_DIR/boj-server/.gitleaksignore" "$GLOBAL_IGNORE" 2>/dev/null || touch "$GLOBAL_IGNORE" -echo "| Repo | Gitleaks Findings | Dependabot Alerts (Crit/High) | Status |" -echo "| --- | --- | --- | --- |" +echo "| Repo | Owner | Gitleaks Findings | Dependabot Alerts (Crit/High) | Status |" +echo "| --- | --- | --- | --- | --- |" # Filter directories and iterate for repo_path in "$REPOS_DIR"/*/; do repo_name=$(basename "$repo_path") - + [ -d "$repo_path" ] || continue [ "$repo_name" = ".git" ] && continue [ "$repo_name" = ".gemini" ] && continue [ "$repo_name" = ".claude" ] && continue [ "$repo_name" = "scripts" ] && continue [ "$repo_name" = "audit_script.sh" ] && continue - + + # --- Ownership filter --- + # Determine the owner from the repo's origin remote and skip anything + # outside the configured allowlist. + repo_owner="$(repo_owner_from_remote "$repo_path" 2>/dev/null || true)" + if [ -z "${repo_owner}" ] || ! owner_allowed "${repo_owner}"; then + echo "| $repo_name | ${repo_owner:-unknown} | - | - | SKIPPED (owner not allowed) |" + continue + fi + # Gitleaks Scan REPORT_FILE=$(mktemp) # Using the user requested flags: --source . --no-git --verbose # We add config and ignore path gitleaks detect --source "$repo_path" --no-git --config "$CONFIG_FILE" --gitleaks-ignore-path "$GLOBAL_IGNORE" --report-path "$REPORT_FILE" --report-format json > /dev/null 2>&1 GITLEAKS_COUNT=$(grep -c "Fingerprint" "$REPORT_FILE" || echo 0) - - # Dependabot Audit + + # Dependabot Audit (uses the actual owner derived from the repo) ENCODED_NAME=$(echo "$repo_name" | sed 's/ /%20/g') - ALERTS_JSON=$(curl -s -H "Authorization: token $TOKEN" "https://api.github.com/repos/hyperpolymath/$ENCODED_NAME/dependabot/alerts?state=open") + ALERTS_JSON=$(curl -s -H "Authorization: token $TOKEN" "https://api.github.com/repos/${repo_owner}/$ENCODED_NAME/dependabot/alerts?state=open") if echo "$ALERTS_JSON" | jq -e '.message == "Not Found" or .message == "Moved Permanently" or .message == "Bad credentials"' > /dev/null 2>&1 || [ -z "$ALERTS_JSON" ]; then DEPENDABOT_COUNT="N/A" @@ -59,7 +74,7 @@ for repo_path in "$REPOS_DIR"/*/; do fi fi - echo "| $repo_name | $GITLEAKS_COUNT | $DEPENDABOT_COUNT | $STATUS |" - + echo "| $repo_name | $repo_owner | $GITLEAKS_COUNT | $DEPENDABOT_COUNT | $STATUS |" + rm "$REPORT_FILE" done diff --git a/scripts/branch-protection-apply.sh b/scripts/branch-protection-apply.sh index a354ea9..2cd2b56 100755 --- a/scripts/branch-protection-apply.sh +++ b/scripts/branch-protection-apply.sh @@ -34,6 +34,15 @@ RULESET_NAME="Base" DRY_RUN=false LIMIT=600 +# --------------------------------------------------------------------------- +# Ownership safety guard +# --------------------------------------------------------------------------- + +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" +assert_owner_allowed "${OWNER}" + if [[ "${1:-}" == "--dry-run" ]]; then DRY_RUN=true echo "[DRY RUN] No changes will be made." diff --git a/scripts/lib/ownership_guard.sh b/scripts/lib/ownership_guard.sh new file mode 100644 index 0000000..acc5c4c --- /dev/null +++ b/scripts/lib/ownership_guard.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: PMPL-1.0-or-later +# +# ownership_guard.sh — refuse to operate on repositories owned by anyone +# outside the configured allowlist. Source this from any script that +# touches GitHub or pushes to remotes. +# +# Public functions: +# owner_allowed — return 0 if allowed, 1 otherwise +# assert_owner_allowed — exit 78 if owner is not allowed +# repo_owner_from_remote — print the GitHub owner of a local repo +# repo_allowed — return 0 if a local repo's owner is allowed +# +# Configuration is loaded from the first existing file: +# $(dirname this)/../../config/owners.config +# /var/mnt/eclipse/repos/git-scripts/config/owners.config +# falling back to a hard-coded ["hyperpolymath"]. + +# Idempotent: only load once per shell. +if [[ "${_OWNERSHIP_GUARD_LOADED:-0}" == "1" ]]; then + return 0 2>/dev/null || true +fi +_OWNERSHIP_GUARD_LOADED=1 + +_GUARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_OWNERS_CONFIG_CANDIDATES=( + "${_GUARD_DIR}/../../config/owners.config" + "/var/mnt/eclipse/repos/git-scripts/config/owners.config" +) + +_loaded_owners_config="" +for _candidate in "${_OWNERS_CONFIG_CANDIDATES[@]}"; do + if [[ -f "${_candidate}" ]]; then + # shellcheck disable=SC1090 + source "${_candidate}" + _loaded_owners_config="${_candidate}" + break + fi +done + +if [[ -z "${_loaded_owners_config}" ]]; then + ALLOWED_OWNERS=("hyperpolymath") +fi + +# Lowercase a string (portable; no `${var,,}` to keep bash 3 compat). +_lc() { + printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]' +} + +# Return 0 if $1 is in ALLOWED_OWNERS (case-insensitive). +owner_allowed() { + local needle + needle="$(_lc "${1:-}")" + [[ -z "${needle}" ]] && return 1 + local allowed + for allowed in "${ALLOWED_OWNERS[@]}"; do + if [[ "${needle}" == "$(_lc "${allowed}")" ]]; then + return 0 + fi + done + return 1 +} + +# Print the owner of a local git repo, derived from `origin` URL. +# Host-agnostic: works for GitHub, GitLab, Bitbucket, Gitea, codeberg, +# self-hosted servers, and SSH-style URLs. The owner is taken as the +# second-to-last path segment (after stripping a trailing .git). +# Returns 1 (and prints nothing) if no owner can be parsed. +repo_owner_from_remote() { + local repo_path="${1:-.}" + local url + url=$(git -C "${repo_path}" config --get remote.origin.url 2>/dev/null) || return 1 + [[ -z "${url}" ]] && return 1 + + # Strip a trailing .git for clean splitting. + url="${url%.git}" + + local path_part="" + + if [[ "${url}" =~ ^[^[:space:]/@]+@[^:]+:(.+)$ ]]; then + # SSH-style: [user@]host:path + path_part="${BASH_REMATCH[1]}" + elif [[ "${url}" =~ ^[a-zA-Z]+://[^/]+(/.+)$ ]]; then + # URL-style: proto://[creds@]host[:port]/path + path_part="${BASH_REMATCH[1]}" + else + return 1 + fi + + # Trim leading/trailing slashes, then take the segment before the last. + path_part="${path_part#/}" + path_part="${path_part%/}" + [[ -z "${path_part}" ]] && return 1 + + local owner_dir owner + owner_dir="$(dirname "${path_part}")" + [[ "${owner_dir}" == "." || "${owner_dir}" == "/" ]] && return 1 + + owner="$(basename "${owner_dir}")" + [[ -z "${owner}" ]] && return 1 + + printf '%s\n' "${owner}" +} + +# Soft check: returns 0 if the local repo's owner is allowed. +repo_allowed() { + local owner + owner="$(repo_owner_from_remote "${1:-.}")" || return 1 + owner_allowed "${owner}" +} + +# Hard guard: print an explanation and exit if the owner is not allowed. +# Use at the top of any script that targets a single org/user. +assert_owner_allowed() { + local owner="${1:-}" + if owner_allowed "${owner}"; then + return 0 + fi + { + echo "" + echo "❌ REFUSING to operate on owner '${owner}'." + echo " This owner is not in the allowlist for git-scripts." + echo " Allowed owners: ${ALLOWED_OWNERS[*]}" + echo "" + echo " To allow it, edit:" + if [[ -n "${_loaded_owners_config}" ]]; then + echo " ${_loaded_owners_config}" + else + echo " config/owners.config" + fi + echo " …or set GIT_SCRIPTS_ALLOWED_OWNERS=\"owner1 owner2\" in the environment." + echo "" + } >&2 + exit 78 # EX_CONFIG +} diff --git a/scripts/md_to_adoc_converter.sh b/scripts/md_to_adoc_converter.sh index a2a3418..10ac605 100755 --- a/scripts/md_to_adoc_converter.sh +++ b/scripts/md_to_adoc_converter.sh @@ -3,6 +3,11 @@ # Simple Markdown to Asciidoc Converter # Handles basic markdown elements for README conversion +# --- Ownership safety guard --- +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" + convert_markdown_to_adoc() { local md_file="$1" local adoc_file="$2" @@ -33,10 +38,18 @@ REPOS_DIR="${REPOS_DIR:-/var/mnt/eclipse/repos}" find "$REPOS_DIR" -maxdepth 2 -name "README.md" -type f | while read md_file; do repo_dir=$(dirname "$md_file") adoc_file="$repo_dir/README.adoc" - + + # --- Ownership filter (only convert files in repos we own) --- + if [[ -d "$repo_dir/.git" ]]; then + if ! repo_allowed "$repo_dir"; then + echo "Skipping: $md_file (owner not in allowlist)" + continue + fi + fi + echo "Converting: $md_file" convert_markdown_to_adoc "$md_file" "$adoc_file" - + # Remove the original markdown file rm "$md_file" echo " → Created: $adoc_file" diff --git a/scripts/project-tabs-audit.sh b/scripts/project-tabs-audit.sh index 99ede55..7aac499 100755 --- a/scripts/project-tabs-audit.sh +++ b/scripts/project-tabs-audit.sh @@ -24,6 +24,15 @@ set -euo pipefail OWNER="hyperpolymath" +# --------------------------------------------------------------------------- +# Ownership safety guard +# --------------------------------------------------------------------------- + +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" +assert_owner_allowed "${OWNER}" + # Mandatory topics — every repo SHOULD have these MANDATORY_TOPICS=("hyperpolymath" "palimpsest") diff --git a/scripts/standardize_readmes.sh b/scripts/standardize_readmes.sh index 5330dc1..2fe1f15 100644 --- a/scripts/standardize_readmes.sh +++ b/scripts/standardize_readmes.sh @@ -7,6 +7,11 @@ REPOS_DIR="${REPOS_DIR:-/var/mnt/eclipse/repos}" LOG_FILE="$HOME/Desktop/readme_standardization.log" BACKUP_DIR="$HOME/Desktop/readme_backups" +# --- Ownership safety guard --- +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" + if ! command -v pandoc >/dev/null 2>&1; then echo "Error: pandoc is not installed. Please install it to use this script." exit 1 @@ -36,8 +41,15 @@ find "$REPOS_DIR"/*/ -maxdepth 0 -type d | while read repo; do if [[ ! -d "$repo/.git" ]]; then continue fi - + repo_name=$(basename "$repo") + + # --- Ownership filter --- + if ! repo_allowed "$repo"; then + echo "Skipping: $repo_name (owner not in allowlist)" >> "$LOG_FILE" + continue + fi + echo "Processing: $repo_name" >> "$LOG_FILE" # Check what README files exist diff --git a/scripts/update_repos.sh b/scripts/update_repos.sh index 07a225b..9c07c67 100755 --- a/scripts/update_repos.sh +++ b/scripts/update_repos.sh @@ -8,6 +8,11 @@ else BASE_DIR="${REPOS_DIR:-/var/mnt/eclipse/repos}" fi +# --- Ownership safety guard --- +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" + if [[ -z "${REPOS:-}" ]]; then echo "Warning: REPOS list is empty or not loaded." # Attempt to find all repos if list is empty @@ -15,17 +20,31 @@ if [[ -z "${REPOS:-}" ]]; then fi FAILURES=() +SKIPPED_OWNERSHIP=() for REPO in "${REPOS[@]}"; do REPO_PATH="$BASE_DIR/$REPO" echo "Processing $REPO..." - + if [ ! -d "$REPO_PATH/.git" ]; then echo "Error: $REPO_PATH is not a git repository." FAILURES+=("$REPO (not a git repo)") continue fi - + + # --- Per-repo ownership filter (refuse to push to foreign owners) --- + repo_owner="$(repo_owner_from_remote "$REPO_PATH" 2>/dev/null || true)" + if [ -z "${repo_owner}" ]; then + echo "Skipping $REPO: no GitHub origin remote (cannot verify owner)." + SKIPPED_OWNERSHIP+=("$REPO (no github origin)") + continue + fi + if ! owner_allowed "${repo_owner}"; then + echo "Skipping $REPO: owner '${repo_owner}' is not in the allowlist." + SKIPPED_OWNERSHIP+=("$REPO (owner=${repo_owner})") + continue + fi + cd "$REPO_PATH" || continue # 0. Sync from remote first (Hiccup prevention) @@ -96,3 +115,11 @@ echo "Persistent Failures:" for FAIL in "${FAILURES[@]}"; do echo "- $FAIL" done + +if [ "${#SKIPPED_OWNERSHIP[@]}" -gt 0 ]; then + echo "" + echo "Skipped (ownership guard):" + for SKIP in "${SKIPPED_OWNERSHIP[@]}"; do + echo "- $SKIP" + done +fi diff --git a/scripts/wiki-audit.sh b/scripts/wiki-audit.sh index f04e0e9..417f284 100755 --- a/scripts/wiki-audit.sh +++ b/scripts/wiki-audit.sh @@ -18,6 +18,12 @@ set -euo pipefail OWNER="hyperpolymath" TMPDIR="" +# --- Ownership safety guard --- +_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/ownership_guard.sh +source "${_SCRIPT_DIR}/lib/ownership_guard.sh" +assert_owner_allowed "${OWNER}" + # Key repos to always check (subset for quick audits) KEY_REPOS=( boj-server proven echidna gossamer typed-wasm ephapax