diff --git a/README.md b/README.md index f239ee9..a95d830 100644 --- a/README.md +++ b/README.md @@ -388,7 +388,7 @@ Copy the skills into a specific project (`/.agents/skills` and `/.cl make project TARGET=/path/to/repo ``` -Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers three hooks: its prompt hook delivers `broadcast` and `direct` items on the next prompt, a PostToolBatch hook delivers `security`, `pan-pan` and `mayday` items mid-turn, and an `asyncRewake` Stop hook wakes an idle session for a `mayday` or a `direct` item sent with `--wake`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. Existing settings are preserved. +Both commands also patch the target's `.claude/settings.local.json` — the machine-local, untracked file, so a project's reviewed `.claude/settings.json` is never rewritten by an install: the caveman and ponytail plugins are enabled so their modes auto-activate on session start, and two reminder hooks are registered — `kvb-dream` points each agent at `/kvb-dream` at most once every twelve hours (stamped per session in `~/.claude/.dream-stamps/`), and `kvb-coach` runs its short form at the first prompt of every full hour, once across all agents (stamped in `~/.claude/.coach-hours/`); the global install also enables the `kvb-dream-notify.timer` systemd user timer, which recommends `/kvb-dream` in a GNOME notification daily at 17:00 local time and catches up once after a missed run. A PreToolUse gate from the `kvb-org-organize` skill blocks edits to files in a git worktree until the session holds a claim (`skills/kvb-org-organize/scripts/council.py`), and its resource watch (`skills/kvb-org-organize/scripts/watch.py`) measures disk, Docker and caches, cleans up low-risk data and blocks new heavy jobs before the disk runs out; it stays inactive until `~/.claude/agents-council/config.json` exists, and because its hooks run that config's commands outside the sandbox, the install adds `Edit(~/.claude/agents-council/**)` to `permissions.deny`, which also covers Write in every permission mode. The `kvb-org-radio` skill registers three hooks: its prompt hook delivers `broadcast` and `direct` items on the next prompt, a PostToolBatch hook delivers `security`, `pan-pan` and `mayday` items mid-turn, and an `asyncRewake` Stop hook wakes an idle session for a `mayday` or a `direct` item sent with `--wake`. The PreToolUse hook of the `kvb-org-queue` skill sends heavy commands through `queue.py hold` once the config sets `queue_enforce`, and holds a held command to the settings' Bash deny and ask rules, which see only `queue.py hold`. The global install also installs the MEDUSA security scanner with pipx for `kvb-check-security`, unless `medusa` is already on the PATH. Two hooks of the `kvb-org-perform` skill (`skills/kvb-org-perform/scripts/log.py`) log every Agent spawn with its task class, model and effort, and store the quality feedback a sandboxed session prints, in `~/.local/state/agents-council/subagent-model.jsonl`. Every hook runs `/usr/bin/python3 -I`, so neither the `python3` of a sandbox-writable virtualenv on the `PATH` nor `PYTHONPATH` reaches the hooks, which run outside the sandbox; older bare `python3` hook entries are rewritten. The reminder hooks run `/usr/bin/bash` and pin `PATH=/usr/bin:/bin`, and the council scripts keep only the `PATH` directories this uid cannot write (`skills/kvb-org-organize/scripts/hookpath.py`), so no binary an agent plants on the inherited `PATH` runs outside the sandbox. Existing settings are preserved. Restart your agent afterwards so it loads the new skills. diff --git a/scripts/enable-plugins.js b/scripts/enable-plugins.js index 26e8a96..396b21e 100644 --- a/scripts/enable-plugins.js +++ b/scripts/enable-plugins.js @@ -61,6 +61,7 @@ for (const [plugin, marketplace] of Object.entries(RETIRED_PLUGINS)) { data.env = data.env || {}; if (!data.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW) data.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = "500000"; const PYTHON = "/usr/bin/python3 -I"; +const ABSOLUTE = { python3: PYTHON, bash: "/usr/bin/bash" }; const COUNCIL_CONFIG_DENY = "Edit(~/.claude/agents-council/**)"; data.permissions = data.permissions || {}; data.permissions.deny = [...new Set([...(data.permissions.deny || []), COUNCIL_CONFIG_DENY])]; @@ -91,7 +92,9 @@ for (const entries of Object.values(data.hooks)) { for (const [from, to] of Object.entries(RENAMED)) { hook.command = hook.command.replace(`/skills/${from}`, `/skills/${to}`); } - if (hook.command.includes("/skills/kvb-")) hook.command = hook.command.replace(/(^|&& )python3 /, `$1${PYTHON} `); + if (hook.command.includes("/skills/kvb-")) { + hook.command = hook.command.replace(/(^|&& )(python3|bash) /, (_, lead, binary) => `${lead}${ABSOLUTE[binary]} `); + } } } } @@ -101,7 +104,7 @@ for (const skill of REMINDERS) { if (alreadyRegistered.includes(`${skill}/hooks/reminder.sh`)) continue; const reminder = path.join(path.dirname(settingsPath), "skills", skill, "hooks", "reminder.sh"); data.hooks.UserPromptSubmit.push({ - hooks: [{ type: "command", command: `bash ${JSON.stringify(reminder)}` }], + hooks: [{ type: "command", command: `${ABSOLUTE.bash} ${JSON.stringify(reminder)}` }], }); } diff --git a/skills/kvb-coach/hooks/reminder.sh b/skills/kvb-coach/hooks/reminder.sh index 180e012..0813c7b 100755 --- a/skills/kvb-coach/hooks/reminder.sh +++ b/skills/kvb-coach/hooks/reminder.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +export PATH=/usr/bin:/bin hours="${COACH_STAMP_DIR:-${HOME}/.claude/.coach-hours}" hour="${COACH_HOUR:-$(date +%Y-%m-%dT%H)}" diff --git a/skills/kvb-dream/hooks/reminder.sh b/skills/kvb-dream/hooks/reminder.sh index c361790..0bff80c 100755 --- a/skills/kvb-dream/hooks/reminder.sh +++ b/skills/kvb-dream/hooks/reminder.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +export PATH=/usr/bin:/bin interval="${DREAM_INTERVAL_SECONDS:-43200}" stamps="${DREAM_STAMP_DIR:-${HOME}/.claude/.dream-stamps}" diff --git a/skills/kvb-org-organize/scripts/council.py b/skills/kvb-org-organize/scripts/council.py index 5c11fb7..6bbf294 100755 --- a/skills/kvb-org-organize/scripts/council.py +++ b/skills/kvb-org-organize/scripts/council.py @@ -6,6 +6,7 @@ Environment: /tmp/agents-council unless set, because all agents must agree on it. CLAUDE_CODE_SESSION_ID: the caller's session, taken over --session; other runtimes pass --session. + PATH: filtered to the directories hookpath.py trusts before git runs. Exit codes: 0 done, 2 usage error, 3 office held by a live session, @@ -282,6 +283,7 @@ def main() -> int: listing.set_defaults(func=cmd_list) sub.add_parser("gate").set_defaults(func=cmd_gate) args = parser.parse_args() + os.environ["PATH"] = sibling("hookpath").trusted(os.environ.get("PATH", "")) return args.func(args) diff --git a/skills/kvb-org-organize/scripts/hookpath.py b/skills/kvb-org-organize/scripts/hookpath.py new file mode 100644 index 0000000..371c195 --- /dev/null +++ b/skills/kvb-org-organize/scripts/hookpath.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +from pathlib import Path + + +def sealed(directory: Path) -> bool: + try: + stats = [part.stat() for part in (directory, *directory.parents)] + except OSError: + return False + me = os.getuid() + return all((st.st_uid != me or st.st_uid == 0) and not st.st_mode & 0o022 for st in stats) + + +def trusted(path: str) -> str: + """Filter a PATH value. + + Args: + path: the PATH value, os.pathsep-separated. + + Returns: + The real paths of its directories that, with every parent, belong to + root or another uid and that neither group nor others can write, in + their order. + """ + kept: list[str] = [] + for entry in filter(None, path.split(os.pathsep)): + try: + real = Path(entry).resolve(strict=True) + except OSError: + continue + if sealed(real) and str(real) not in kept: + kept.append(str(real)) + return os.pathsep.join(kept) diff --git a/skills/kvb-org-organize/scripts/watch.py b/skills/kvb-org-organize/scripts/watch.py index 842384a..2b126cc 100755 --- a/skills/kvb-org-organize/scripts/watch.py +++ b/skills/kvb-org-organize/scripts/watch.py @@ -32,6 +32,8 @@ Environment: its commands outside the sandbox; a Docker daemon the sandbox can reach still writes it. Without a config file every command exits 0 and does nothing. + PATH: filtered to the directories hookpath.py trusts before any command + runs, the config's commands included. Config keys, all required: disk, thresholds_gb {warn, stop, critical}, projection_minutes, interval_seconds, cache_interval_seconds, heavy_commands, @@ -73,7 +75,8 @@ def sibling(name: str) -> ModuleType: nofollow = sibling("nofollow") -ROOT = Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council")) +hookpath = sibling("hookpath") +ROOT =Path(os.environ.get("AGENTS_COUNCIL_ROOT", "/tmp/agents-council")) CONFIG = Path( os.environ.get( "AGENTS_COUNCIL_CONFIG", @@ -209,7 +212,7 @@ def notify(cfg: dict, text: str) -> None: def spawn_cleanup(mode: str) -> None: subprocess.Popen( - [sys.executable, str(Path(__file__).resolve()), "cleanup", mode], + [sys.executable, "-I", str(Path(__file__).resolve()), "cleanup", mode], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) @@ -523,6 +526,7 @@ def main() -> int: mode.add_argument("--full", action="store_true") sub.add_parser("status") args = parser.parse_args() + os.environ["PATH"] = hookpath.trusted(os.environ.get("PATH", "")) refused = guard_root(hook=args.cmd in ("watch", "block")) if refused is not None: return refused diff --git a/skills/kvb-org-radio/scripts/radio.py b/skills/kvb-org-radio/scripts/radio.py index 59240b5..53f4c3d 100755 --- a/skills/kvb-org-radio/scripts/radio.py +++ b/skills/kvb-org-radio/scripts/radio.py @@ -9,6 +9,8 @@ Environment: of a mayday; ~/.claude/agents-council/config.json unless set. CLAUDE_CODE_SESSION_ID: the calling session; hooks read the payload's session_id first, other runtimes pass --session. + PATH: filtered to the directories the kvb-org-organize hookpath.py + trusts before the notify argv runs. Args: send "" [--to ...] [--wake] [--session ]: add a @@ -468,6 +470,7 @@ def main() -> int: p.add_argument("--session") p.set_defaults(func=cmd_show) args = parser.parse_args() + os.environ["PATH"] = organize("hookpath").trusted(os.environ.get("PATH", "")) refused = organize("watch").guard_root(hook=args.command in ("hook", "tool", "listen")) if refused is not None: return refused diff --git a/tests/test_coach.py b/tests/test_coach.py index 7a95634..49f0f94 100644 --- a/tests/test_coach.py +++ b/tests/test_coach.py @@ -89,6 +89,28 @@ class TestCoachHour(unittest.TestCase): self.assertIn("kvb-coach", self._run("2026-11-03T15")) self.assertEqual(sorted(p.name for p in self.hours.iterdir()), ["2026-11-03T15"]) + def test_a_binary_planted_on_the_inherited_path_never_runs(self): + planted = Path(self.tmp.name) / "planted" + planted.mkdir() + marker = Path(self.tmp.name) / "ran" + for name in ("mkdir", "find", "cat", "date"): + fake = planted / name + fake.write_text(f"#!/bin/sh\n/usr/bin/touch '{marker}'\n", encoding="utf-8") + fake.chmod(0o755) + subprocess.run( + ["bash", str(HOOK)], + check=True, + capture_output=True, + text=True, + env={ + "PATH": f"{planted}:/usr/bin:/bin", + "HOME": self.tmp.name, + "COACH_STAMP_DIR": str(self.hours), + "COACH_HOUR": "2026-11-03T14", + }, + ) + self.assertFalse(marker.exists()) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_dream.py b/tests/test_dream.py index 48b2529..0b0ac89 100644 --- a/tests/test_dream.py +++ b/tests/test_dream.py @@ -97,6 +97,29 @@ class TestDreamHook(unittest.TestCase): self.assertEqual(self._run(payload), "") self.assertFalse(self.stamps.exists()) + def test_a_binary_planted_on_the_inherited_path_never_runs(self): + planted = Path(self.tmp.name) / "planted" + planted.mkdir() + marker = Path(self.tmp.name) / "ran" + for name in ("python3", "date", "mkdir", "cat"): + fake = planted / name + fake.write_text(f"#!/bin/sh\n/usr/bin/touch '{marker}'\n", encoding="utf-8") + fake.chmod(0o755) + subprocess.run( + ["bash", str(HOOK)], + input=self._session("a1"), + capture_output=True, + text=True, + check=True, + env={ + "PATH": f"{planted}:/usr/bin:/bin", + "HOME": self.tmp.name, + "DREAM_STAMP_DIR": str(self.stamps), + "DREAM_INTERVAL_SECONDS": "43200", + }, + ) + self.assertFalse(marker.exists()) + class TestDreamNotification(unittest.TestCase): def setUp(self): @@ -233,6 +256,19 @@ class TestSettingsRegistration(unittest.TestCase): self.assertNotRegex(command, r"(^|&& )python3 ") self.assertEqual(len(self._hooks(data, "PreToolUse", "kvb-org-queue/scripts/queue.py")), 1) + def test_reminders_run_the_system_bash_and_old_entries_move_to_it(self): + old = f'bash "{self.tmp.name}/skills/kvb-dream/hooks/reminder.sh"' + self.settings.write_text( + json.dumps({"hooks": {"UserPromptSubmit": [{"hooks": [{"type": "command", "command": old}]}]}}), + encoding="utf-8", + ) + data = self._patch() + reminders = [hook["command"] for hook in self._hooks(data, "UserPromptSubmit", "/hooks/reminder.sh")] + self.assertEqual(len(reminders), 2) + for command in reminders: + with self.subTest(command=command): + self.assertTrue(command.startswith("/usr/bin/bash ")) + def test_council_config_deny_is_added_once_next_to_existing_rules(self): self.settings.write_text(json.dumps({"permissions": {"deny": ["Bash(rm -rf:*)"]}}), encoding="utf-8") self._patch() diff --git a/tests/test_hookpath.py b/tests/test_hookpath.py new file mode 100644 index 0000000..1cc8175 --- /dev/null +++ b/tests/test_hookpath.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +HOOKPATH = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "hookpath.py" + + +def _load(): + spec = importlib.util.spec_from_file_location("hookpath_under_test", HOOKPATH) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@unittest.skipIf(os.getuid() == 0, "every directory root creates is root-owned") +class TestHookpath(unittest.TestCase): + def test_only_sealed_directories_survive_as_real_paths(self): + hookpath = _load() + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp) + own = base / "bin" + own.mkdir() + link = base / "system" + link.symlink_to("/usr/bin") + path = os.pathsep.join([str(own), str(link), "", "/tmp", str(base / "missing"), "/usr/bin", "/bin"]) + expected = list(dict.fromkeys(str(Path(d).resolve()) for d in ("/usr/bin", "/bin"))) + self.assertEqual(hookpath.trusted(path).split(os.pathsep), expected) + self.assertEqual(hookpath.trusted(str(own)), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_radio.py b/tests/test_radio.py index b459e0f..ce6f2fd 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -416,6 +416,24 @@ class TestRadio(unittest.TestCase): self.assertEqual(self._tool("s2"), ["MAYDAY One: fire"]) self.assertEqual(os.listdir(victims), []) + def test_the_operator_notice_runs_no_binary_planted_on_the_path(self): + planted = self.root.parent / "planted" + planted.mkdir() + marker = self.root.parent / "ran" + fake = planted / "council-probe" + fake.write_text(f"#!/bin/sh\n/usr/bin/touch '{marker}'\n", encoding="utf-8") + fake.chmod(0o755) + self.config.write_text(json.dumps({"notify": ["council-probe"]}), encoding="utf-8") + self._send("s1", "send", "mayday", "fire") + result = subprocess.run( + [sys.executable, str(RADIO), "tool"], + input=json.dumps({"session_id": "s2"}), capture_output=True, text=True, + env={**self._env(None), "PATH": str(planted)}, check=False, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("MAYDAY", result.stdout) + self.assertFalse(marker.exists()) + def test_the_hooks_skip_a_session_lock_another_process_holds(self): self._send("s1", "post", "board") lock = self.root / "radio-seen" / "s2.lock" diff --git a/tests/test_watch.py b/tests/test_watch.py index a8b437e..e004ec2 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -406,6 +406,32 @@ class TestWatchCommands(unittest.TestCase): ) self.assertEqual(result.returncode, 0, result.stderr) + def test_the_watch_hook_runs_no_binary_planted_on_the_path(self): + planted = self.base / "planted" + planted.mkdir() + marker = self.base / "ran" + for name in ("docker", "council-probe"): + fake = planted / name + fake.write_text(f"#!/bin/sh\n/usr/bin/touch '{marker}'\n", encoding="utf-8") + fake.chmod(0o755) + self.cfg["notify"] = ["council-probe"] + self.cfg["thresholds_gb"] = {"warn": 1e9, "stop": 1e9, "critical": 1e9} + self._write_config() + env = { + "PATH": str(planted), + "HOME": str(self.base), + "AGENTS_COUNCIL_ROOT": str(self.root), + "AGENTS_COUNCIL_CONFIG": str(self.config), + } + payload = {"session_id": "s1", "hook_event_name": "UserPromptSubmit"} + result = subprocess.run( + [sys.executable, str(WATCH), "watch"], + capture_output=True, text=True, env=env, input=json.dumps(payload), check=False, timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("RESOURCE CRITICAL", result.stdout) + self.assertFalse(marker.exists()) + def test_the_watch_hook_skips_an_alert_line_that_is_no_alert(self): self.root.mkdir() (self.root / "alerts.jsonl").write_text(