From 2f6bf3166cec64c52359d180b499627cb62b3bbc Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Tue, 6 Oct 2026 20:22:19 -0400 Subject: [PATCH 01/68] fix(agent): adopt, update and roll back stacks that Portainer runs Portainer runs compose inside its own container, so the paths compose writes into the labels (/data/compose/49/docker-compose.yml) are Portainer's, not the host's, and adopting such a stack failed with "No such file or directory". A label path that does not exist on the host is now mapped through the mount of the container that holds it (longest destination wins), and used only if the mapped file exists. The env file compose recorded (Portainer's stack.env) is passed with --env-file, so a re-up from the host interpolates the same values instead of blanks, and adopt reads the env from it. --- agent/tests/test_compose_host_paths.py | 210 ++++++++++++++++++++++++ agent/tests/test_stack_actions.py | 4 +- agent/tests/test_stack_read.py | 13 ++ agent/tests/test_update_container.py | 4 +- agent/vigil_agent/actions/containers.py | 83 +++++++++- agent/vigil_agent/actions/stacks.py | 3 +- 6 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 agent/tests/test_compose_host_paths.py diff --git a/agent/tests/test_compose_host_paths.py b/agent/tests/test_compose_host_paths.py new file mode 100644 index 00000000..dbab4391 --- /dev/null +++ b/agent/tests/test_compose_host_paths.py @@ -0,0 +1,210 @@ +"""QA-07: compose paths written by a manager that runs compose in its own +container (Portainer) are the container's, not the host's — map them through +the mount that holds them before compose or Vigil reads them.""" +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import tests._safety_net # noqa: F401 — the guard, even when this file is run or imported on its own + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +# isort: split +from vigil_agent import engine, executor +from vigil_agent.actions import containers +from vigil_agent.config import AgentConfig + +from tests.fake_engine import FakeEngine + +_CFG = AgentConfig(server_url="https://v", agent_token="t", mode="full_control") +PORTAINER_COMPOSE = "/data/compose/49/docker-compose.yml" +PORTAINER_ENV = "/data/compose/49/stack.env" +PORTAINER_WD = "/data/compose/49" + + +def _mount(source: str, destination: str) -> dict: + return {"Type": "bind", "Source": source, "Destination": destination} + + +class HostPathTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + p = patch.object(executor, "_validate_path", side_effect=lambda v, label: Path(v)) + p.start() + self.addCleanup(p.stop) + + def _engine(self, containers_): + fake = FakeEngine(routes={("GET", "/containers/json"): (200, containers_)}) + self.addCleanup(fake.close) + p = patch.object(executor, "_engine", return_value=engine.EngineClient(fake.path)) + p.start() + self.addCleanup(p.stop) + return fake + + def test_existing_path_is_unchanged(self): + existing = self.root / "docker-compose.yml" + existing.write_text("services: {}\n") + fake = self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "host"), "/data")]}]) + self.assertEqual(containers._host_path(str(existing)), str(existing)) + self.assertFalse([r for r in fake.requests if "/containers/json" in r[1]]) + + def test_maps_through_the_containers_mount(self): + host = self.root / "host" / "compose" / "49" + host.mkdir(parents=True) + compose = host / "docker-compose.yml" + compose.write_text("services:\n searxng:\n image: searxng/searxng\n") + self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "host"), "/data"), + _mount("/var/run/docker.sock", "/var/run/docker.sock")]}]) + self.assertEqual(containers._host_path(PORTAINER_COMPOSE), str(compose)) + + def test_longest_destination_wins(self): + a = self.root / "a" / "compose" / "49" + b = self.root / "b" / "49" + a.mkdir(parents=True) + b.mkdir(parents=True) + (b / "docker-compose.yml").write_text("services: {}\n") + self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "b"), "/data/compose"), + _mount(str(self.root / "a"), "/data")]}]) + self.assertEqual(containers._host_path(PORTAINER_COMPOSE), str(b / "docker-compose.yml")) + + def test_unexplained_path_is_unchanged(self): + self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "elsewhere"), "/srv")]}]) + self.assertEqual(containers._host_path(PORTAINER_COMPOSE), PORTAINER_COMPOSE) + + def test_a_mount_that_maps_to_nothing_keeps_the_label_path(self): + self._engine([{"Id": "p", "Mounts": [_mount(str(self.root / "host"), "/data")]}]) + self.assertEqual(containers._host_path(PORTAINER_COMPOSE), PORTAINER_COMPOSE) + + def test_engine_error_is_unchanged(self): + """An agent with no answering engine keeps the label path — the + caller's error then names it. _mount_lookup swallows nothing else.""" + from vigil_agent.engine import EngineError + for boom in (RuntimeError("No container engine found (Docker or Podman socket)"), + EngineError("engine refused", status=500), + OSError("socket gone"), + ValueError("not json")): + p = patch.object(executor, "_engine", side_effect=boom) + p.start() + self.addCleanup(p.stop) + self.assertEqual(containers._host_path(PORTAINER_COMPOSE), PORTAINER_COMPOSE) + + def test_the_labelled_path_is_what_an_unexplained_failure_names(self): + """The fallback of last resort is the label itself, so an admin who + hits it sees the path Portainer's own stack entry shows.""" + self._engine([{"Id": "p", "Mounts": []}]) + self.assertEqual(containers._host_path("/data/compose/49/.env"), + "/data/compose/49/.env") + self.assertEqual(containers._label_env_files( + {"com.docker.compose.project.environment_file": "/data/compose/49/stack.env"}), []) + + +class PortainerActionTests(unittest.TestCase): + """The actions the cphmserv report hit, end to end through the seams: the + argv that reaches compose carries host paths, never a container's.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.host = Path(self.tmp.name) / "portainer_data" + stack = self.host / "compose" / "49" + stack.mkdir(parents=True) + (stack / "docker-compose.yml").write_text("services:\n searxng:\n image: searxng/searxng\n") + (stack / "stack.env").write_text("SEARXNG_SECRET=abc\n") + self.compose = stack / "docker-compose.yml" + self.env = stack / "stack.env" + self.fake = FakeEngine(routes={("GET", "/containers/json"): (200, [ + {"Id": "p", "Mounts": [_mount(str(self.host), "/data"), + _mount("/var/run/docker.sock", "/var/run/docker.sock")]}])}) + self.addCleanup(self.fake.close) + self.calls = [] + for name, value in (("_engine", engine.EngineClient(self.fake.path)), + ("_compose_cmd", ["docker", "compose"]), + ("_compose_env", {})): + p = patch.object(executor, name, return_value=value) + p.start() + self.addCleanup(p.stop) + p = patch.object(executor, "_run", side_effect=lambda cmd, timeout=60, extra_env=None: + self.calls.append(cmd) or "ok") + p.start() + self.addCleanup(p.stop) + p = patch.object(executor, "_validate_path", side_effect=lambda v, label: Path(v)) + p.start() + self.addCleanup(p.stop) + p = patch.object(executor.collector, "request_docker_recheck") + p.start() + self.addCleanup(p.stop) + + def test_portainer_container_rollback(self): + spec = {"Config": { + "Image": "searxng/searxng:latest", + "Labels": {"com.docker.compose.project": "searxng", + "com.docker.compose.service": "searxng", + "com.docker.compose.project.config_files": PORTAINER_COMPOSE, + "com.docker.compose.project.working_dir": PORTAINER_WD, + "com.docker.compose.project.environment_file": PORTAINER_ENV}}} + with patch.object(executor, "_docker_inspect", return_value=spec): + executor._container_rollback({"container_name": "searxng", + "image": "searxng/searxng:2025-09-17"}, _CFG) + + cmd = self.calls[0] + override = str(self.host / "compose" / "49" / containers.ROLLBACK_OVERRIDE) + self.assertEqual( + cmd, + ["docker", "compose", "-p", "searxng", "--project-directory", + str(self.host / "compose" / "49"), "--env-file", str(self.env), + "-f", str(self.compose), "-f", override, "up", "-d", "--no-deps", "searxng"]) + self.assertFalse([a for a in cmd if a.startswith("/data/")], + f"a container path leaked into the host's argv: {cmd!r}") + self.assertEqual((self.host / "compose" / "49" / containers.ROLLBACK_OVERRIDE).read_text(), + "# Written by Vigil: searxng rolled back. The next update removes it.\n" + "services:\n searxng:\n image: \"searxng/searxng:2025-09-17\"\n") + + def test_portainer_stack_compose_args(self): + self.fake.routes[("GET", "/containers/json")] = (200, [{ + "Id": "s", + "Labels": {"com.docker.compose.project": "searxng", + "com.docker.compose.project.config_files": PORTAINER_COMPOSE, + "com.docker.compose.project.working_dir": PORTAINER_WD, + "com.docker.compose.project.environment_file": PORTAINER_ENV}, + "Mounts": [_mount(str(self.host), "/data")]}]) + self.assertEqual( + containers._stack_compose_args("searxng"), + ["-p", "searxng", "--project-directory", str(self.host / "compose" / "49"), + "--env-file", str(self.env), "-f", str(self.compose)]) + + def test_portainer_update_container(self): + spec = {"Image": "sha256:old", "Config": { + "Image": "searxng/searxng:latest", + "Labels": {"com.docker.compose.project": "searxng", + "com.docker.compose.service": "searxng", + "com.docker.compose.project.config_files": PORTAINER_COMPOSE, + "com.docker.compose.project.working_dir": PORTAINER_WD, + "com.docker.compose.project.environment_file": PORTAINER_ENV}}} + with patch.object(executor, "_docker_inspect", return_value=spec), \ + patch.object(executor, "_pull", return_value=""), \ + patch.object(executor, "_image_id", return_value="sha256:new"), \ + patch.object(executor, "_container_image_id", return_value="sha256:new"): + executor._update_container({"container_name": "searxng"}, _CFG) + + self.assertEqual(len(self.calls), 2) + tail = ["--project-directory", str(self.host / "compose" / "49"), + "--env-file", str(self.env), "-f", str(self.compose)] + self.assertEqual(self.calls[0], ["docker", "compose", *tail, "pull", "searxng"]) + self.assertEqual(self.calls[1], ["docker", "compose", *tail, + "up", "-d", "--no-deps", "searxng"]) + for cmd in self.calls: # both compose runs carry the host's argv + self.assertFalse([a for a in cmd if a.startswith("/data/")], + f"a container path leaked into the host's argv: {cmd!r}") + # compose writes the labels from the paths it is handed, so the same + # re-up has to carry the env file — otherwise it interpolates blanks + # and recreates every service of the stack. + self.assertEqual(tail.count("--env-file"), 1) + self.assertEqual(tail[tail.index("--env-file") + 1], str(self.env)) + + +if __name__ == "__main__": + unittest.main() diff --git a/agent/tests/test_stack_actions.py b/agent/tests/test_stack_actions.py index 8e9923d7..95bfb17a 100644 --- a/agent/tests/test_stack_actions.py +++ b/agent/tests/test_stack_actions.py @@ -48,7 +48,9 @@ def test_restart_uses_the_labels(self): "-f", "/opt/media/compose.yaml", "-f", "/opt/media/extra.yaml", "restart"]) self.assertEqual(env, {"DOCKER_HOST": f"unix://{self.fake.path}"}) self.assertEqual(out.data, {"project": "media"}) - query = self.fake.requests[-1][1] + # The mount lookup QA-07 adds is also a /containers/json call, so the + # filtered request this asserts on is found by name, not by position. + query = [q for _, q, _ in self.fake.requests if "filters=" in (q or "")][-1] self.assertIn("filters=", query) self.assertIn("com.docker.compose.project%3Dmedia", query) diff --git a/agent/tests/test_stack_read.py b/agent/tests/test_stack_read.py index 2ef13714..2e04325f 100644 --- a/agent/tests/test_stack_read.py +++ b/agent/tests/test_stack_read.py @@ -31,6 +31,7 @@ def setUp(self): "com.docker.compose.config-hash": h, "com.docker.compose.project.config_files": str(self.dir / "docker-compose.yml"), "com.docker.compose.project.working_dir": str(self.dir)} + self.labels = labels("web", "h-web") self.fake = FakeEngine(routes={("GET", "/containers/json"): (200, [ {"Id": "a", "Labels": labels("web", "h-web")}, {"Id": "b", "Labels": labels("db", "h-old")}])}) self.addCleanup(self.fake.close) @@ -59,6 +60,18 @@ def test_reads_posts_and_keeps_secrets_out_of_the_result(self): self.assertEqual(out.data, {"project": "shop", "would_recreate": 1}) self.assertEqual(self.run_mock.call_args.args[0][-3:], ["config", "--hash", "*"]) + def test_reads_env_from_the_recorded_env_file(self): + """Portainer passes --env-file stack.env; adopt reads that, not .env.""" + (self.dir / "stack.env").write_text("A=1\n") + self.fake.routes[("GET", "/containers/json")] = (200, [ + {"Id": "a", "Labels": {**self.labels, + "com.docker.compose.project.environment_file": + str(self.dir / "stack.env")}}]) + (self.dir / ".env").unlink() + with patch.object(client, "post_stack_read") as post: + stacks._stack_read({"project": "shop", "adopt_ticket": TICKET}, _CFG) + self.assertEqual(post.call_args.args[2]["env"], "A=1\n") + def test_refusals(self): with patch.object(client, "post_stack_read") as post: for params in ({"project": "shop", "adopt_ticket": "nope"}, {"project": "Shop", "adopt_ticket": TICKET}): diff --git a/agent/tests/test_update_container.py b/agent/tests/test_update_container.py index 121d16f1..5359d9ed 100644 --- a/agent/tests/test_update_container.py +++ b/agent/tests/test_update_container.py @@ -18,7 +18,7 @@ import tempfile import unittest from pathlib import Path -from unittest.mock import patch +from unittest.mock import MagicMock, patch sys.path.insert(0, str(Path(__file__).resolve().parents[1])) @@ -68,6 +68,8 @@ def fake_run(cmd, timeout=60, extra_env=None): stack.enter_context(patch.object(executor, "_container_image_id", return_value=after)) stack.enter_context(patch.object(executor, "_compose_cmd", return_value=["docker", "compose"])) stack.enter_context(patch.object(executor, "_compose_env", return_value={})) + stack.enter_context(patch.object( + executor, "_engine", return_value=MagicMock(get=MagicMock(return_value=[])))) mock = stack.enter_context(patch.object(executor, "_recreate_container", return_value="recreated")) if recreate else None yield mock diff --git a/agent/vigil_agent/actions/containers.py b/agent/vigil_agent/actions/containers.py index cf1345ab..44e85c36 100644 --- a/agent/vigil_agent/actions/containers.py +++ b/agent/vigil_agent/actions/containers.py @@ -28,6 +28,22 @@ def _engine(): return ex._engine() +def _mount_lookup(): + """Every running container, for the mount lookup ``_host_path`` needs. + Unfiltered: the container holding the mount (Portainer itself) carries no + label of this project, so a project filter could never find it. None when + there is no engine to ask — the caller's error then names the label path, + which is what the admin can match. One lookup serves every path of an + action; ``_host_path`` and ``_label_env_files`` take it as ``known``.""" + try: + return _engine().get("/containers/json") or [] + except (RuntimeError, OSError, ValueError): + # no engine at all, or one that won't answer (EngineError is a + # RuntimeError): no mapping. The caller's error still names the + # labelled path, which is what the admin can see. + return None + + def _lifecycle(params: dict, verb: str) -> ActionOutput: name = _validate_name( params.get("container_name") or params.get("container_id", ""), @@ -368,15 +384,16 @@ def _update_container(params: dict, _config: AgentConfig) -> str: f"compose labels are missing config files — use " f"docker_compose_up with an explicit compose_file" ) - compose_file = str(ex._validate_path(compose_file, "compose_file")) + known = _mount_lookup() + compose_file = str(ex._validate_path(_host_path(compose_file, known), "compose_file")) - project_dir = labels.get("com.docker.compose.project.working_dir") or "" + project_dir = _host_path(labels.get("com.docker.compose.project.working_dir") or "", known) dir_args = [] if project_dir: dir_args = ["--project-directory", str(ex._validate_path(project_dir, "project directory"))] _clear_rollback(project_dir) # an update ends a rollback - cmd = [*ex._compose_cmd(), *dir_args, "-f", compose_file] + cmd = [*ex._compose_cmd(), *dir_args, *_label_env_files(labels, known), "-f", compose_file] ex._run(cmd + ["pull", service], timeout=600, extra_env=ex._compose_env()) ex._run(cmd + ["up", "-d", "--no-deps", service], timeout=300, extra_env=ex._compose_env()) @@ -480,16 +497,64 @@ def _stack_compose_args(project: str) -> list[str]: if not files: continue args = ["-p", project] - workdir = labels.get("com.docker.compose.project.working_dir") or "" + # One engine lookup serves the workdir, the env files and the compose files. + known = _mount_lookup() + workdir = _host_path(labels.get("com.docker.compose.project.working_dir") or "", known) if workdir: args += ["--project-directory", str(ex._validate_path(workdir, "project directory"))] + args += _label_env_files(labels, known) for f in files: - args += ["-f", str(ex._validate_path(f, "compose file"))] + args += ["-f", str(ex._validate_path(_host_path(f, known), "compose file"))] return args raise ValueError(f"No containers of stack {project!r} carry compose file labels — " f"deploy it with docker_compose_up and an explicit compose_file") +def _host_path(raw: str, known=None) -> str: + """The host's path for a path compose wrote into a label. + + A stack started by a manager that runs compose in its own container + (Portainer: /data/compose//docker-compose.yml) carries that container's + paths. Map them through the mount that holds them; a path that already + exists, or that no mount explains, comes back unchanged. ``known`` is the + caller's ``_mount_lookup()``, so one action asks the engine once. + """ + if not raw or Path(raw).exists(): + return raw + target = Path(raw) + if not target.is_absolute() or ".." in target.parts: + # a relative label can be read as escaping the mount it maps through; + # leave it to the caller, whose error names the labelled path + return raw + if known is None: + known = _mount_lookup() + if known is None: + return raw + best = None + for c in known: + for m in c.get("Mounts") or []: + dest, src = str(m.get("Destination") or ""), str(m.get("Source") or "") + if not dest.startswith("/") or not src: + continue + d = Path(dest) + if (target == d or d in target.parents) and (best is None or len(d.parts) > len(best[0].parts)): + best = (d, Path(src)) + if best is None: + return raw + mapped = best[1] / target.relative_to(best[0]) + return str(mapped) if mapped.exists() else raw + + +def _label_env_files(labels: dict, known=None) -> list[str]: + """``--env-file`` args for the env files compose recorded, mapped to the host.""" + args = [] + for f in str(labels.get("com.docker.compose.project.environment_file") or "").split(","): + f = _host_path(f.strip(), known) + if f and Path(f).is_file(): + args += ["--env-file", str(ex._validate_path(f, "env file"))] + return args + + def _stack_restart(params: dict, _config: AgentConfig) -> str: project = str(params.get("project") or "") args = _stack_compose_args(project) @@ -548,7 +613,8 @@ def _container_rollback(params: dict, _config: AgentConfig) -> str: if project: service = labels.get("com.docker.compose.service") or name _validate_name(service, "service name") - workdir = labels.get("com.docker.compose.project.working_dir") or "" + known = _mount_lookup() + workdir = _host_path(labels.get("com.docker.compose.project.working_dir") or "", known) files = [f.strip() for f in str(labels.get("com.docker.compose.project.config_files") or "") .split(",") if f.strip() and not f.strip().endswith(ROLLBACK_OVERRIDE)] if not files or not workdir: @@ -557,9 +623,10 @@ def _container_rollback(params: dict, _config: AgentConfig) -> str: override = wd / ROLLBACK_OVERRIDE override.write_text(f"# Written by Vigil: {service} rolled back. The next update removes it.\n" f"services:\n {service}:\n image: {json.dumps(image)}\n") - cmd = [*ex._compose_cmd(), "-p", project, "--project-directory", str(wd)] + cmd = [*ex._compose_cmd(), "-p", project, "--project-directory", str(wd), + *_label_env_files(labels, known)] for f in files: - cmd += ["-f", str(ex._validate_path(f, "compose file"))] + cmd += ["-f", str(ex._validate_path(_host_path(f, known), "compose file"))] cmd += ["-f", str(override), "up", "-d", "--no-deps", service] ex._run(cmd, timeout=300, extra_env=ex._compose_env()) via = "compose override" diff --git a/agent/vigil_agent/actions/stacks.py b/agent/vigil_agent/actions/stacks.py index 680850b7..4a5d8803 100644 --- a/agent/vigil_agent/actions/stacks.py +++ b/agent/vigil_agent/actions/stacks.py @@ -159,7 +159,8 @@ def _stack_read(params: dict, config: AgentConfig) -> str: compose_path = Path(files[0]) workdir = Path(args[args.index("--project-directory") + 1]) if "--project-directory" in args \ else compose_path.parent - env_path = workdir / ".env" + env_path = Path(args[args.index("--env-file") + 1]) if "--env-file" in args \ + else workdir / ".env" payload = { "project": project, "compose_file": compose_path.name, From 4e6f2d9e5aee2a5820f68f74940f8da9c75e4186 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Tue, 6 Oct 2026 20:54:20 -0400 Subject: [PATCH 02/68] fix(agent): a managed agent left on the monitor-mode service says so instead of failing every task MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host installed in monitor mode and switched to managed afterwards keeps the monitor unit: User=vigil-agent under ProtectSystem=strict. Every task it accepted then died on its first write — Update Agent with "[Errno 30] Read-only file system: '/usr/local/bin/...'". The agent now rejects each task up front with the fix (re-run the installer), reports runs_as_root at check-in, and the server counts such a host as refusing every action, so the deploy dialog's "will refuse" chip and skip logic catch it before sending. --- agent/tests/test_checkin_allowlist.py | 13 ++++++++ agent/tests/test_privilege_mismatch_advice.py | 29 ++++++++++++++++- agent/vigil_agent/__main__.py | 32 +++++++++++++++---- agent/vigil_agent/client.py | 7 ++++ .../0025_host_agent_runs_as_root.py | 18 +++++++++++ server/apps/hosts/models.py | 3 ++ server/apps/hosts/serializers.py | 1 + server/apps/hosts/views.py | 3 ++ server/apps/tasks/refusals.py | 3 ++ server/apps/tasks/test_refusals.py | 32 +++++++++++++++++++ 10 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 server/apps/hosts/migrations/0025_host_agent_runs_as_root.py diff --git a/agent/tests/test_checkin_allowlist.py b/agent/tests/test_checkin_allowlist.py index 3439cb10..7d0d389e 100644 --- a/agent/tests/test_checkin_allowlist.py +++ b/agent/tests/test_checkin_allowlist.py @@ -54,6 +54,19 @@ def test_empty_allowlist_is_sent(self): client.checkin(cfg, metrics=[]) self.assertEqual(self.sent["allowlist"], []) + def test_payload_says_whether_it_runs_as_root(self): + """QA-08: a managed agent on the monitor-mode unit runs unprivileged, + and the server needs that fact to warn before a deploy.""" + with tempfile.TemporaryDirectory() as tmp: + cfg = AgentConfig(server_url="http://127.0.0.1:1", agent_token="t" * 40, + data_dir=Path(tmp), mode="managed") + with patch.object(client.os, "geteuid", return_value=0): + client.checkin(cfg, metrics=[]) + self.assertIs(self.sent["runs_as_root"], True) + with patch.object(client.os, "geteuid", return_value=1000): + client.checkin(cfg, metrics=[]) + self.assertIs(self.sent["runs_as_root"], False) + if __name__ == "__main__": unittest.main() diff --git a/agent/tests/test_privilege_mismatch_advice.py b/agent/tests/test_privilege_mismatch_advice.py index 1d6cc058..2906f6de 100644 --- a/agent/tests/test_privilege_mismatch_advice.py +++ b/agent/tests/test_privilege_mismatch_advice.py @@ -24,7 +24,7 @@ import unittest from unittest.mock import patch -from vigil_agent.__main__ import _warn_on_privilege_mismatch +from vigil_agent.__main__ import _privilege_mismatch, _process_tasks, _warn_on_privilege_mismatch class _Cfg: @@ -73,5 +73,32 @@ def test_the_advice_does_not_stop_at_removing_user(self): ) +class PrivilegeMismatchRejection(unittest.TestCase): + """QA-08: the warning is invisible from the UI, and the task it warns about + dies on '[Errno 30] Read-only file system' one Errno at a time.""" + + def test_managed_as_non_root_rejects_every_task(self): + tasks = [{"id": "1", "action": "update_agent"}, {"id": "2", "action": "check_service"}] + with patch("vigil_agent.__main__.os.geteuid", return_value=1000): + with patch("vigil_agent.__main__.execute_action") as run, \ + patch("vigil_agent.__main__._report_rejected") as rejected: + _process_tasks(tasks, _Cfg("managed"), None, object()) + self.assertEqual(rejected.call_count, 2, + "every task the agent cannot run must be rejected, not run") + run.assert_not_called() + for call in rejected.call_args_list: + message = call.args[2] + self.assertIn("install.sh", message, + "the only complete fix is re-running the installer") + self.assertIn("managed", message) + + def test_root_managed_is_not_rejected_for_privilege(self): + with patch("vigil_agent.__main__.os.geteuid", return_value=0): + self.assertFalse(_privilege_mismatch(_Cfg("managed"))) + with patch("vigil_agent.__main__.os.geteuid", return_value=1000): + self.assertFalse(_privilege_mismatch(_Cfg("monitor")), + "monitor mode needs no root — it must keep monitoring") + + if __name__ == "__main__": unittest.main() diff --git a/agent/vigil_agent/__main__.py b/agent/vigil_agent/__main__.py index 752604ea..f7bb181a 100644 --- a/agent/vigil_agent/__main__.py +++ b/agent/vigil_agent/__main__.py @@ -61,6 +61,13 @@ def _process_tasks(tasks: list[dict], config, nonce_store: NonceStore, verify_ke ) return + if _privilege_mismatch(config): + # Better one clear rejection now than the task dying on Errno 30 + # halfway through a write (QA-08). + for task in tasks: + _report_rejected(config, task, PRIVILEGE_MISMATCH.format(mode=config.mode)) + return + if verify_key is None: if tasks: logger.warning( @@ -578,6 +585,24 @@ def _report_skipped(config, task: dict, output: str, steps=None) -> None: logger.exception("Failed to report task %s skip", task.get("id")) +#: What a task gets back when this agent cannot run it. +PRIVILEGE_MISMATCH = ( + "This agent's mode ({mode}) runs tasks as root, but its service still runs it as the " + "unprivileged 'vigil-agent' user under the monitor-mode sandbox (read-only /usr and /etc), " + "so every task would fail. Re-run the installer on this host to regenerate the service for " + "its mode: curl -fsSL /agent/install.sh | sudo bash") + + +def _privilege_mismatch(config) -> bool: + """True when the mode needs root and this process is not root (POSIX only).""" + if config.mode == "monitor": + return False + try: + return os.geteuid() != 0 + except AttributeError: # Windows has no geteuid + return False + + def _warn_on_privilege_mismatch(config) -> None: """Say so when the mode needs root and this process does not have it. @@ -604,12 +629,7 @@ def _warn_on_privilege_mismatch(config) -> None: A warning, not a refusal: an agent that stops monitoring because it cannot execute is worse than one that monitors and says it cannot execute. """ - if config.mode == "monitor": - return - try: - if os.geteuid() == 0: - return - except AttributeError: # Windows has no geteuid + if not _privilege_mismatch(config): return logger.warning( diff --git a/agent/vigil_agent/client.py b/agent/vigil_agent/client.py index f23ab0e5..78ab2b39 100644 --- a/agent/vigil_agent/client.py +++ b/agent/vigil_agent/client.py @@ -4,6 +4,7 @@ """ import logging +import os import platform import socket @@ -72,6 +73,12 @@ def checkin( "allowlist": sorted(config.allowlist), "allow_reprovision": bool(config.allow_reprovision), } + # A managed agent left on the monitor-mode unit runs unprivileged and fails + # every task; the server marks such a host as refusing them (QA-08). + try: + payload["runs_as_root"] = os.geteuid() == 0 + except AttributeError: # Windows + pass # The ingest distinguishes an absent key (agent too old to report it — # stored value left alone) from an explicit False, so the key is only # sent when the probe produced a value. diff --git a/server/apps/hosts/migrations/0025_host_agent_runs_as_root.py b/server/apps/hosts/migrations/0025_host_agent_runs_as_root.py new file mode 100644 index 00000000..1476191a --- /dev/null +++ b/server/apps/hosts/migrations/0025_host_agent_runs_as_root.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.17 on 2026-10-07 00:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0024_merge_0018_host_machine_id_0023_container_rollback'), + ] + + operations = [ + migrations.AddField( + model_name='host', + name='agent_runs_as_root', + field=models.BooleanField(blank=True, null=True), + ), + ] diff --git a/server/apps/hosts/models.py b/server/apps/hosts/models.py index a725d440..60a7fdfc 100644 --- a/server/apps/hosts/models.py +++ b/server/apps/hosts/models.py @@ -53,6 +53,9 @@ def save(self, *args, **kwargs): # agent new enough to report it checks in — unknown, which is not "refuses". agent_allowlist = models.JSONField(null=True, blank=True) agent_allow_reprovision = models.BooleanField(default=False) + # Whether the agent's process runs as root, as it last reported it (QA-08): + # None until an agent new enough to report it checks in. + agent_runs_as_root = models.BooleanField(null=True, blank=True) last_checkin = models.DateTimeField(null=True, blank=True) #: What this machine is missing, as the agent last counted it: #: {"pending", "critical", "important", "reboot_required"}. Null means no diff --git a/server/apps/hosts/serializers.py b/server/apps/hosts/serializers.py index 6d9ffe52..e54e4a78 100644 --- a/server/apps/hosts/serializers.py +++ b/server/apps/hosts/serializers.py @@ -27,6 +27,7 @@ class Meta: "agent_version", "agent_allowlist", "agent_allow_reprovision", + "agent_runs_as_root", "reboot_required", "last_checkin", "created_at", diff --git a/server/apps/hosts/views.py b/server/apps/hosts/views.py index b9cb7f7a..9ef2bdb0 100644 --- a/server/apps/hosts/views.py +++ b/server/apps/hosts/views.py @@ -432,6 +432,9 @@ def checkin(request): host.agent_allowlist = sorted(set(raw_allow)) if isinstance(data.get("allow_reprovision"), bool): host.agent_allow_reprovision = data["allow_reprovision"] + # Absent means an agent too old to report it (or Windows) — keep what we knew. + if isinstance(data.get("runs_as_root"), bool): + host.agent_runs_as_root = data["runs_as_root"] if data.get("features") is not None: # A list of short strings the agent understands (task-language diff --git a/server/apps/tasks/refusals.py b/server/apps/tasks/refusals.py index 93513391..424b6e22 100644 --- a/server/apps/tasks/refusals.py +++ b/server/apps/tasks/refusals.py @@ -103,6 +103,9 @@ def refused_actions(host, action_types: set[str]) -> list[str]: ``[]`` when the agent has never reported an allowlist: unknown is not the same as refused, and an old agent must not look broken. """ + if host.agent_runs_as_root is False and host.mode != "monitor": + # Its service is still the monitor-mode unit: it can run nothing (QA-08). + return sorted(set(action_types) & AGENT_ACTIONS) if host.agent_allowlist is None: return [] mode = host.mode diff --git a/server/apps/tasks/test_refusals.py b/server/apps/tasks/test_refusals.py index e3737f6c..f927772a 100644 --- a/server/apps/tasks/test_refusals.py +++ b/server/apps/tasks/test_refusals.py @@ -59,6 +59,26 @@ def test_refused_actions_per_mode(self): # An agent that never reported an allowlist is unknown, not refusing. self.assertEqual(refused_actions(_host("m6", allowlist=None), wanted), []) + def test_unprivileged_managed_host_refuses_everything(self): + """QA-08: an agent still running under the monitor-mode unit's sandbox + fails every task it accepts, so it refuses all of them up front.""" + wanted = {"check_service", "hunt_service", "run_command", "reprovision_stage", "playbook"} + every = sorted(wanted & AGENT_ACTIONS) + + host = _host("p1", allowlist=("check_service", "hunt_service"), reprovision=True) + host.agent_runs_as_root = False + host.save(update_fields=["agent_runs_as_root"]) + self.assertEqual(refused_actions(host, wanted), every) + + host.mode = Host.Mode.FULL_CONTROL + host.save(update_fields=["mode"]) + self.assertEqual(refused_actions(host, wanted), every) + + # Unknown (None) is not "refuses" — an old agent must not look broken. + host.agent_runs_as_root = None + host.save(update_fields=["agent_runs_as_root"]) + self.assertEqual(refused_actions(host, wanted), []) + def test_task_action_types_includes_relevant_probes(self): self.assertEqual(task_action_types(parse_and_validate(RELEVANT)), {"check_service", "hunt_service"}) @@ -126,3 +146,15 @@ def test_checkin_stores_allowlist(self): self._checkin(host) # absent: kept host.refresh_from_db() self.assertEqual(host.agent_allowlist, ["check_service", "hunt_file"]) + + def test_checkin_stores_runs_as_root(self): + """QA-08: the server needs the agent's privilege to warn about a host + whose service is still the monitor-mode unit.""" + host = _host("rr") + self._checkin(host, runs_as_root=False) + host.refresh_from_db() + self.assertIs(host.agent_runs_as_root, False) + self._checkin(host, runs_as_root="no") # malformed: kept + self._checkin(host) # absent: kept + host.refresh_from_db() + self.assertIs(host.agent_runs_as_root, False) From 3c4179b83c07675d6bf27e093f33b3476ec55a88 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Tue, 6 Oct 2026 23:37:18 -0400 Subject: [PATCH 03/68] feat(apps): the Apps page explains itself; "By host" opens rows in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page assumed an operator already knew that "unmanaged" means "seen only in Windows' Apps & features, reported by no package manager" — which is exactly the thing that needs explaining, because it is why such an item can never show as outdated. Each tab now opens with one line saying what it lists, and the unmanaged, outdated and decision columns carry the existing .field-hint markers, so the copy is one hover away instead of a footnote at the bottom of one tab. "By host" jumped to the Monitor page, which read as a broken link: the tab promises a per-host software list, so the row expands in place the way a "By app" row does, fetching /software/hosts// once and caching it. The jump to Monitor is still there, now as an explicit link inside the row. MUTATION RED: removing data-app-host-row from the row template turns test_by_host_expands_in_place red; restored and green. End-to-end (rendered template, not source scan): field-hint markers: 8 apps-intro: 3 upd-note: 0 hints: 3x unmanaged (Apps & features / cannot upgrade or uninstall, paragraphs intact), 1 outdated, 3 bulk decisions, 1 decision column (Approved / Declined / Undecided). Ran 2182 tests in 112.371s — OK (skipped=1); manage.py check: no issues. --- server/apps/software/test_apps_page.py | 68 ++++++++++++++- server/static/css/vigil.css | 11 ++- server/static/js/vigil-apps.js | 110 +++++++++++++++++++++++-- server/templates/pages/_apps.html | 22 ++--- 4 files changed, 190 insertions(+), 21 deletions(-) diff --git a/server/apps/software/test_apps_page.py b/server/apps/software/test_apps_page.py index a950c907..b31f3ebb 100644 --- a/server/apps/software/test_apps_page.py +++ b/server/apps/software/test_apps_page.py @@ -3,10 +3,12 @@ The UI has no JS runner (the architect drives it in Chromium), so its wiring is pinned by source scan: the ids and calls the page depends on. """ +import html +import re from pathlib import Path from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from rest_framework.test import APIClient from apps.accounts.models import Role, UserProfile @@ -110,3 +112,67 @@ def test_apps_js_escapes_api_values(self): f"{field} reaches innerHTML unescaped") self.assertRegex(js, r"/software/apps/\$\{encodeURIComponent\(", "name_key must be URL-encoded in the app detail URL") + + +class AppsExplainedTests(SimpleTestCase): + """The page explains itself, and "By host" expands instead of navigating away.""" + + def _rendered_hints(self): + """The Apps page as the browser receives it, plus its decoded hints.""" + from django.template.loader import render_to_string + + rendered = render_to_string("pages/_apps.html", {}) + # The hint names the Windows "Apps & features" setting; the attribute + # carries it escaped, so decode it the way the browser would. + return rendered, [html.unescape(h) + for h in re.findall(r'data-hint="([^"]*)"', rendered)] + + def test_unmanaged_is_explained(self): + _rendered, hints = self._rendered_hints() + with_manager = [h for h in hints if "package manager" in h] + self.assertGreaterEqual(len(with_manager), 3, + "the unmanaged filter and both Unmanaged columns explain it") + self.assertTrue(any("cannot upgrade or uninstall" in h for h in hints), + "unmanaged must say what Vigil cannot do") + + def test_decisions_are_explained(self): + _rendered, hints = self._rendered_hints() + for phrase in ("without waiting out", "exclude list", "goes back to its own rules"): + self.assertTrue(any(phrase in h for h in hints), phrase) + self.assertNotIn("upd-note", _source("templates", "pages", "_apps.html"), + "the bottom note is replaced by the hints") + + def test_each_tab_has_an_intro(self): + rendered, _hints = self._rendered_hints() + self.assertEqual(rendered.count('class="apps-intro"'), 3, + "by app, by host and by update each open with one line") + + def test_by_host_expands_in_place(self): + js = _source("static", "js", "vigil-apps.js") + for literal in ('data-app-host-row="${', "function toggleHostDetail", + "/api/v1/software/hosts/", "data-host-show-all="): + self.assertIn(literal, js, literal) + row_handler = re.search(r"delegateClick\('\[data-app-host-row\]',[\s\S]*?\n\}\);", js) + self.assertIsNotNone(row_handler, "the host row is not wired through delegateClick") + self.assertNotIn("navigateTo", row_handler.group(0), + "clicking a host row must expand it, not leave the page") + + def test_the_page_renders_its_new_markup(self): + """End-to-end: what the browser gets is what the hints promise. + + A bare ampersand inside an attribute would silently truncate the + sentence it appears in, so this counts the markers and reads their + decoded values rather than trusting the template source. + """ + from django.template.loader import render_to_string + + rendered = render_to_string("pages/_apps.html", {}) + self.assertEqual(rendered.count("upd-note"), 0) + self.assertEqual(rendered.count('class="field-hint"'), 8, + "every hint the page promises renders") + _rendered, hints = self._rendered_hints() + unmanaged = [h for h in hints if "Apps & features" in h] + self.assertEqual(len(unmanaged), 3, "the filter and both Unmanaged columns") + self.assertTrue(all("cannot upgrade or uninstall" in h for h in unmanaged)) + self.assertTrue(all("\n\n" in h for h in unmanaged), + "the unmanaged hint's paragraphs must survive into the attribute") diff --git a/server/static/css/vigil.css b/server/static/css/vigil.css index efc0275c..8c3ac1f7 100644 --- a/server/static/css/vigil.css +++ b/server/static/css/vigil.css @@ -3765,13 +3765,22 @@ button.fcn:focus-visible { outline: 2px solid var(--sky); outline-offset: 3px; } .apps-host-toolbar { margin-bottom: 10px; } .apps-host-toolbar .form-control { max-width: 320px; font-size: 12px; } .apps-show-all { margin-top: 10px; } +.apps-intro { font-size: 13px; color: var(--text-3); margin: 4px 0 14px; max-width: 72ch; } +.apps-chev { display: inline-block; color: var(--text-3); margin-right: 2px; + transition: transform 0.2s var(--ease-out-expo); } +.apps-row.open .apps-chev { transform: rotate(90deg); } +.apps-host-detail { margin: 0; } +.apps-host-detail th, .apps-host-detail td { font-size: 12.5px; } +.apps-host-open { display: inline-block; margin-top: 8px; font-size: 12.5px; + color: var(--sky-ink); text-decoration: none; } +.apps-host-open:hover { text-decoration: underline; } /* ── Apps: by update (M8) ────────────────────────────────────────────── */ .apps-table th.upd-check, .apps-table td.upd-check { width: 1%; padding-right: 4px; } .upd-title { font-size: 11px; color: var(--text-3); } .upd-bulk { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 12px; color: var(--text-2); } -.upd-note { margin-top: 10px; font-size: 12px; } +.upd-bulk .field-hint { margin-left: -2px; } .chip-mint { background: rgba(var(--rgb-mint), 0.14); color: var(--mint-ink); } .chip-lemon { background: rgba(var(--rgb-lemon), 0.16); color: var(--lemon-ink); } diff --git a/server/static/js/vigil-apps.js b/server/static/js/vigil-apps.js index 29666231..b364746e 100644 --- a/server/static/js/vigil-apps.js +++ b/server/static/js/vigil-apps.js @@ -4,7 +4,7 @@ // HTML: templates/pages/_apps.html, Software section in templates/pages/_monitor.html // Depends on: vigil-utils.js (escHtml, escAttr, timeAgo, delegateClick), // vigil-nav.js (navigateTo — wrapped below), vigil-monitor.js -// (selectMonitorHost, reached from a hostname link). +// (selectMonitorHost, reached from the "Open this host's monitor" link). // API: GET /api/v1/software/apps/, /api/v1/software/apps//, // /api/v1/software/hosts/, /api/v1/software/hosts//, // /api/v1/software/updates/, /api/v1/software/updates/hosts/; @@ -26,6 +26,9 @@ const appsState = { hostSeq: 0, details: {}, // name_key → host rows, fetched once per app; null = in flight expanded: {}, // name_key → true while its detail row is open + hostDetails: {}, // host_id → software items, fetched once per host; null = in flight + hostExpanded: {}, // host_id → true while its detail row is open + hostShowAll: {}, // host_id → true once "Show all" was clicked monitorItems: [], monitorSnapshot: null, monitorSeq: 0, @@ -209,19 +212,95 @@ async function fetchAppHosts() { function renderAppsHostsTable() { const tbody = document.getElementById('apps-hosts-body'); if (!tbody) return; - const html = appsState.hosts.map(row => ` - ${escHtml(row.hostname)} - ${row.item_count} - ${_countChip(row.outdated, 'chip-rose')} - ${_countChip(row.unmanaged, 'apps-chip-warn')} - ${escHtml(timeAgo(row.received_at) || 'never')} - ${_errorChips(row.errors) || '—'} - `).join(''); + const html = appsState.hosts.map(row => { + const open = appsState.hostExpanded[row.host_id] ? ' open' : ''; + let out = ` + ▸ ${escHtml(row.hostname)} + ${row.item_count} + ${_countChip(row.outdated, 'chip-rose')} + ${_countChip(row.unmanaged, 'apps-chip-warn')} + ${escHtml(timeAgo(row.received_at) || 'never')} + ${_errorChips(row.errors) || '—'} + `; + if (appsState.hostExpanded[row.host_id]) out += _hostDetailHtml(row.host_id); + return out; + }).join(''); tbody.innerHTML = html || _emptyRow('No hosts in your scope.'); renderAppsTable(); } +function _hostSoftwareSorted(items) { + // The Monitor page's Software list orders this way; By host's expanded rows match it. + return [...items].sort((a, b) => { + if (!!b.outdated !== !!a.outdated) return b.outdated ? 1 : -1; + if (!!b.managed !== !!a.managed) return b.managed ? 1 : -1; + return String(a.name || '').localeCompare(String(b.name || '')); + }); +} + +function _hostDetailHtml(id) { + const items = appsState.hostDetails[id]; + if (items === null || items === undefined) { + return ` +
Loading software…
`; + } + if (!items.length) { + return ` +
No software reported for this host yet.
`; + } + const sorted = _hostSoftwareSorted(items); + const showAll = !!appsState.hostShowAll[id]; + const shown = showAll ? sorted : sorted.slice(0, APPS_HOST_DETAIL_LIMIT); + const rows = shown.map(it => { + const latest = it.outdated + ? ` ${escHtml(it.latest_version)}` : ''; + const unmanaged = it.managed ? '' : ' unmanaged'; + return ` + ${escHtml(it.name)}${latest}${unmanaged} + ${escHtml(it.version)} + ${escHtml(it.latest_version)} + ${escHtml(it.source)} + ${escHtml(_scopeCell(it))} + `; + }).join(''); + const more = (!showAll && sorted.length > APPS_HOST_DETAIL_LIMIT) + ? `
+ +
` : ''; + return ` + + + ${rows} +
NameVersionLatestSourceScope
+ ${more} + Open this host's monitor → + `; +} + +async function toggleHostDetail(id) { + if (appsState.hostExpanded[id]) { + delete appsState.hostExpanded[id]; + renderAppsHostsTable(); + return; + } + appsState.hostExpanded[id] = true; + renderAppsHostsTable(); + if (appsState.hostDetails[id] !== undefined) return; + appsState.hostDetails[id] = null; + let items = []; + try { + const resp = await fetch(`/api/v1/software/hosts/${id}/`, { credentials: 'same-origin' }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + items = (await resp.json()).items || []; + } catch { items = []; } + appsState.hostDetails[id] = items; + if (appsState.hostExpanded[id]) renderAppsHostsTable(); +} + function refreshApps() { + appsState.hostDetails = {}; + appsState.hostExpanded = {}; + appsState.hostShowAll = {}; fetchApps(true); fetchAppHosts(); fetchUpdates(); @@ -430,6 +509,9 @@ function renderHostSoftwareList() { const SOFTWARE_ROWS = 100; let _softwareShowAll = false; +// An expanded "By host" row shows this many packages; a Linux host reports ~1500. +const APPS_HOST_DETAIL_LIMIT = 50; + /* ── Wiring ──────────────────────────────────────────────────────────── */ let _appsSearchTimer = null; @@ -485,6 +567,16 @@ delegateClick('[data-upd-row]', (el, ev) => { delegateClick('[data-upd-decide]', (el) => decideUpdates(el.dataset.updDecide)); +delegateClick('[data-app-host-row]', (el, ev) => { + if (ev.target.closest('a, button')) return; + toggleHostDetail(el.dataset.appHostRow); +}); + +delegateClick('[data-host-show-all]', (el) => { + appsState.hostShowAll[el.dataset.hostShowAll] = true; + renderAppsHostsTable(); +}); + delegateClick('[data-app-host]', (el, ev) => { ev.preventDefault(); navigateTo('monitor'); diff --git a/server/templates/pages/_apps.html b/server/templates/pages/_apps.html index d1e45c03..d8748048 100644 --- a/server/templates/pages/_apps.html +++ b/server/templates/pages/_apps.html @@ -4,7 +4,7 @@ +

Stacks, rollouts and sessions

+
+ + + + + + + + + + + + + + + + + + + + + + +
MethodPathDescription
GET/api/v1/stacks/?host={id}A host's managed stacks
POST/api/v1/stacks/Create a stack: a compose_yaml, or git: {url, branch, pin, path, credential_id}
POST/api/v1/stacks/validate/Check a compose file without saving
GET/api/v1/stacks/{id}/Stack detail
PUT/api/v1/stacks/{id}/Update a stack
DELETE/api/v1/stacks/{id}/Delete a stack
GET/api/v1/stacks/{id}/revisions/The stack's revision list
POST/api/v1/stacks/{id}/deploy/Deploy the stack (TOTP)
POST/api/v1/stacks/{id}/remove/Take the stack down (TOTP)
POST/api/v1/stacks/{id}/env/reveal/Show the stored .env values (TOTP)
POST/api/v1/stacks/{id}/pull/Fetch the repository now (Git stacks)
GET/api/v1/stacks/git-credentials/List Git credentials
POST/api/v1/stacks/git-credentials/Add a Git credential (needs TOTP; the secret is never returned)
DELETE/api/v1/stacks/git-credentials/{id}/Remove a Git credential
POST/api/v1/rollouts/Start a rollout (wave_group_tags for several groups)
POST/api/v1/rollouts/batch/{batch}/halt/Halt a rollout batch (TOTP)
GET/api/v1/accounts/session/Time left on this sign-in
POST/api/v1/accounts/session/activity/The heartbeat
+
+

Task definitions and history

@@ -4221,6 +4329,8 @@

Core

+ +
VIGIL_TIMEZONEUTCIANA timezone for display and scheduling (e.g. America/Chicago).
VIGIL_TIME_FORMAT12h12h or 24h clock display.
VIGIL_SKIP_STARTUP_CHECKSunsetSet 1 to bypass startup validation. Escape hatch for tooling/CI, not for production.
VIGIL_SESSION_IDLE_MINUTES15Minutes without use before a sign-in ends (minimum 5).
VIGIL_SESSION_MAX_HOURS12Hours after which a sign-in ends regardless of use (minimum 1).
From 5e60b0321adac10a26a847990a45ce79638ff977 Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 15:27:45 -0400 Subject: [PATCH 67/68] docs(wiki): style the PUT method badge the API table now uses --- wiki/vigil-wiki.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wiki/vigil-wiki.html b/wiki/vigil-wiki.html index 03e16d65..88bcf557 100644 --- a/wiki/vigil-wiki.html +++ b/wiki/vigil-wiki.html @@ -173,7 +173,7 @@ .api-method { display: inline-block; font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 4px; letter-spacing: 0.5px; min-width: 50px; text-align: center; } .m-get { background: rgba(130,196,238,0.15); color: var(--sky-ink); } .m-post { background: rgba(126,221,181,0.15); color: var(--mint-ink); } - .m-patch { background: rgba(240,184,136,0.15); color: var(--peach-ink); } + .m-patch, .m-put { background: rgba(240,184,136,0.15); color: var(--peach-ink); } .m-delete { background: rgba(242,160,184,0.15); color: var(--rose-ink); } .steps { counter-reset: step; margin: 16px 0 20px; } From 463618dcff3bfc1e09ecde7acd9b6bb0b18eb09c Mon Sep 17 00:00:00 2001 From: Connor Haggerty Date: Sat, 10 Oct 2026 15:41:04 -0400 Subject: [PATCH 68/68] docs: no emoji in the wiki, the app, the README or the editions table Words instead: Yes / Partly in the Podman and editions tables; the warnings read as plain sentences. --- README.md | 2 +- docs/EDITIONS.md | 38 +++++++++++++-------------- server/static/js/vigil-stacks.js | 2 +- server/templates/pages/_settings.html | 2 +- server/templates/pages/_vulns.html | 1 - wiki/vigil-wiki.html | 10 +++---- 6 files changed, 27 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index a6bde84e..566f5632 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,7 @@ python3 -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())" VIGIL_SIGNING_KEY_SEED=Hk3PtP...P0c= ``` -> ⚠️ **If you're pasting the compose stack into Portainer** (or any other UI that round-trips through YAML), set the value in the **Environment variables** tab — never inline it into the YAML. If you must inline it, **wrap it in double quotes** (`"Hk3...P0c="`) — the trailing `=` is base64 padding and unquoted YAML can strip or mangle it. A malformed seed produces `binascii.Error: Incorrect padding` and breaks every agent checkin in the fleet. +> **If you're pasting the compose stack into Portainer** (or any other UI that round-trips through YAML), set the value in the **Environment variables** tab — never inline it into the YAML. If you must inline it, **wrap it in double quotes** (`"Hk3...P0c="`) — the trailing `=` is base64 padding and unquoted YAML can strip or mangle it. A malformed seed produces `binascii.Error: Incorrect padding` and breaks every agent checkin in the fleet. **3. Verify the seed is good before bringing the stack up:** diff --git a/docs/EDITIONS.md b/docs/EDITIONS.md index de545482..d90de92a 100644 --- a/docs/EDITIONS.md +++ b/docs/EDITIONS.md @@ -20,28 +20,28 @@ with `seats: 9999`). SSO/SAML is "available — talk to us," not a tier. | Feature | Free | Business | |---|:--:|:--:| -| Monitoring, alerting (smart defaults), unlimited agents/hosts/retention | ✅ | ✅ | -| Remote task execution + agent (agent is 100% OSS, no tier awareness) | ✅ | ✅ | -| Vuln scanning (Trivy / Nessus / Greenbone) | ✅ | ✅ | -| Vigil's own vulnerability matcher (OSV / KEV / EPSS, offline bundle), evidence and fix groups | ✅ | ✅ | -| Remediation by fix (deploy a fix to every affected host), AI mitigations when there is no fix | ✅ | ✅ | -| Detection tasks (severity / cves / boost), Organization · Community · Vendor tags, Anvil SARIF import | ✅ | ✅ | -| Containers & stacks (Docker / Podman): lifecycle, logs, rollback, single-host stack editor and .env, adopt | ✅ | ✅ | -| Audit trail of stack and .env events (viewer) | — | ✅ | -| **Baselines** (auto-dispatch on host approval) | ✅ | ✅ | -| **AI suggestions** — BYO endpoint, any OpenAI-compatible or Anthropic | ✅ | ✅ | -| **Status page** (public token URL, Powered-by-Vigil badge) | ✅ | ✅ | -| Jackil integration (alert → ticket) | ✅ | ✅ | -| **App & patch policies** (present / latest / pinned / absent, patching windows, deferral, approve each change) | ✅ | ✅ | -| Fleet view by update, fleet-wide approve / decline | ✅ | ✅ | -| Patch compliance numbers (% of hosts patched within SLA) | ✅ | ✅ | -| **Compliance report** — pass/fail per site, CSV export, branded printable report with "Licensed to" | — | ✅ | +| Monitoring, alerting (smart defaults), unlimited agents/hosts/retention | Yes | Yes | +| Remote task execution + agent (agent is 100% OSS, no tier awareness) | Yes | Yes | +| Vuln scanning (Trivy / Nessus / Greenbone) | Yes | Yes | +| Vigil's own vulnerability matcher (OSV / KEV / EPSS, offline bundle), evidence and fix groups | Yes | Yes | +| Remediation by fix (deploy a fix to every affected host), AI mitigations when there is no fix | Yes | Yes | +| Detection tasks (severity / cves / boost), Organization · Community · Vendor tags, Anvil SARIF import | Yes | Yes | +| Containers & stacks (Docker / Podman): lifecycle, logs, rollback, single-host stack editor and .env, adopt | Yes | Yes | +| Audit trail of stack and .env events (viewer) | — | Yes | +| **Baselines** (auto-dispatch on host approval) | Yes | Yes | +| **AI suggestions** — BYO endpoint, any OpenAI-compatible or Anthropic | Yes | Yes | +| **Status page** (public token URL, Powered-by-Vigil badge) | Yes | Yes | +| Jackil integration (alert → ticket) | Yes | Yes | +| **App & patch policies** (present / latest / pinned / absent, patching windows, deferral, approve each change) | Yes | Yes | +| Fleet view by update, fleet-wide approve / decline | Yes | Yes | +| Patch compliance numbers (% of hosts patched within SLA) | Yes | Yes | +| **Compliance report** — pass/fail per site, CSV export, branded printable report with "Licensed to" | — | Yes | | Seats | 1 admin + 1 viewer | **per-seat pricing** | | Roles | Admin, Viewer | + **Operator**, custom roles | | **Sites** (administrative boundaries; per-site scoping) | 1 (default) | **unlimited** | -| **Audit log** viewer + CSV export (recording is always on) | — | ✅ | -| **Status page branding** (logo, no badge, per-site client pages) | — | ✅ | -| **Branding** (reports, portal) | — | ✅ | +| **Audit log** viewer + CSV export (recording is always on) | — | Yes | +| **Status page branding** (logo, no badge, per-site client pages) | — | Yes | +| **Branding** (reports, portal) | — | Yes | | SSO/SAML | — | talk to us | **Seats are the only meter.** Sites are unlimited on Business because seats diff --git a/server/static/js/vigil-stacks.js b/server/static/js/vigil-stacks.js index da3b47de..d19aebca 100644 --- a/server/static/js/vigil-stacks.js +++ b/server/static/js/vigil-stacks.js @@ -216,7 +216,7 @@ function _renderStackCheck(r) { + (r.line ? `line ${r.line}` : '') + ''; } (r.missing_env || []).forEach((v) => { - html += `
⚠ ${escHtml(v)} is used but not set ` + html += `
${escHtml(v)} is used but not set ` + `
`; }); diff --git a/server/templates/pages/_settings.html b/server/templates/pages/_settings.html index eb00f4c2..188f7247 100644 --- a/server/templates/pages/_settings.html +++ b/server/templates/pages/_settings.html @@ -113,7 +113,7 @@

Add a provider

Civil SSO

Opt in to one account across every SQSY app (Vigil, Jackil, Sigil, Turnstil) via a Civil identity server. Local login always keeps working; when Civil is down, existing sessions are unaffected. Leave disabled for a standalone install.

- +
diff --git a/server/templates/pages/_vulns.html b/server/templates/pages/_vulns.html index 7a0a52ed..95eeb316 100644 --- a/server/templates/pages/_vulns.html +++ b/server/templates/pages/_vulns.html @@ -83,7 +83,6 @@