fix(kvb-org-organize): no hook runs a binary planted on the inherited PATH

Hooks run outside the sandbox but inherit a PATH whose directories
sandboxed agents of the same uid can write (a virtualenv's bin activated
first by the shell profile), so a file planted there under the name of
docker, git or notify-send ran unsandboxed. hookpath.py keeps only the
PATH directories that, with every parent, belong to another uid and that
neither group nor others can write, as real paths; inside the sandbox's
user namespace root shows as the overflow uid and still counts as sealed.
watch.py, radio.py and council.py set that PATH before they start
anything, and the cleanup child runs with -I. Both reminder hooks pin
PATH=/usr/bin:/bin, and the install registers them with /usr/bin/bash and
moves older bare bash and python3 entries to the absolute paths.

🎯 Fix: 90 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
💰 Value: ?
🏆 Score: 86
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 13:27:00 +00:00
1 parent 94542e7b92
commit 454670a77e
13 files changed
+193 -5

No files matched your search

+1 -1
View File
@@ -388,7 +388,7 @@ Copy the skills into a specific project (`<repo>/.agents/skills` and `<repo>/.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.
+5 -2
View File
@@ -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)}` }],
});
}
+1
View File
@@ -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)}"
+1
View File
@@ -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}"
@@ -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)
@@ -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)
+6 -2
View File
@@ -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
+3
View File
@@ -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 <class> "<text>" [--to <name> ...] [--wake] [--session <id>]: 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
+22
View File
@@ -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()
+36
View File
@@ -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()
+37
View File
@@ -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()
+18
View File
@@ -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"
+26
View File
@@ -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(