fix(kvb-org-organize): no council hook follows a planted symlink or FIFO
The council root is writable by every sandboxed agent of the same uid while its hooks run outside the sandbox, so a planted symlink turned a hook's write into one outside the root, and a planted FIFO or held lock hung every agent's hooks. - Add nofollow.py: open, read, lock, append, replace and unlink below a root through O_NOFOLLOW directory fds, O_NONBLOCK and regular files only; reads stop at 64 MiB and replace undecodable bytes; locks take an explicit wait. - watch, queue, radio and council read and write everything below the council root through it; keep_rows copies no symlinked file into ~/.local/state. - watch: read_json returns dicts only, alert lines that are no alert (deep nesting included) are skipped, cmd_cleanup makes and removes its lock through a root dir fd, and the cache trim walks with fwalk, counts allocated blocks and each inode once. - radio: the hooks wait at most 2 s for a session lock and skip the delivery past it. - council: read_claim ignores claims that are no object with session, runtime and beat, so a malformed claim no longer disables the claim gate. MEDUSA acceptances (kvb-think-criticize 2026-11-06, skeptics A and B): MM-SCAN-058 on nofollow.replace callers, B105 and HPT006 in history.py, RT-SCAN-054 in council.py (name matches). 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 88 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3843821674
commit
cb815216d2
11 files changed
+791
-97
No files matched your search
@@ -251,6 +251,167 @@ class TestWatchCommands(unittest.TestCase):
|
||||
self.root.symlink_to(target)
|
||||
self.assertEqual(self._run("status").returncode, 2)
|
||||
|
||||
def _victims(self) -> tuple[Path, Path]:
|
||||
victim = self.base / "victim"
|
||||
victim.write_text("keep", encoding="utf-8")
|
||||
victims = self.base / "victims"
|
||||
victims.mkdir()
|
||||
return victim, victims
|
||||
|
||||
def _warn_hook(self, session: str) -> subprocess.CompletedProcess:
|
||||
self.cfg["thresholds_gb"] = {"warn": 1e9, "stop": -1, "critical": -2}
|
||||
self._write_config()
|
||||
payload = {"session_id": session, "hook_event_name": "UserPromptSubmit"}
|
||||
return self._run("watch", stdin=json.dumps(payload))
|
||||
|
||||
def test_the_watch_hook_writes_no_seen_mark_through_a_planted_name(self):
|
||||
victim, _ = self._victims()
|
||||
(self.root / "seen").mkdir(parents=True)
|
||||
(self.root / "seen" / "s1").symlink_to(victim)
|
||||
result = self._warn_hook("s1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("RESOURCE WARN", result.stdout)
|
||||
self.assertEqual(victim.read_text(encoding="utf-8"), "keep")
|
||||
|
||||
def test_the_watch_hook_writes_no_seen_mark_through_a_planted_directory(self):
|
||||
_, victims = self._victims()
|
||||
self.root.mkdir()
|
||||
(self.root / "seen").symlink_to(victims)
|
||||
result = self._warn_hook("s1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("RESOURCE WARN", result.stdout)
|
||||
self.assertEqual(os.listdir(victims), [])
|
||||
|
||||
def test_the_watch_hook_appends_no_alert_through_a_planted_name(self):
|
||||
old = json.dumps({"id": 0, "stage": "ok", "text": "old"}) + "\n"
|
||||
victim, _ = self._victims()
|
||||
victim.write_text(old, encoding="utf-8")
|
||||
self.root.mkdir()
|
||||
(self.root / "alerts.jsonl").symlink_to(victim)
|
||||
result = self._warn_hook("s1")
|
||||
self.assertEqual((result.returncode, result.stdout), (0, ""), result.stderr)
|
||||
self.assertEqual(victim.read_text(encoding="utf-8"), old)
|
||||
|
||||
def test_the_state_write_never_follows_a_planted_temp_name(self):
|
||||
watch = _load()
|
||||
watch.ROOT = self.root
|
||||
victim, _ = self._victims()
|
||||
self.root.mkdir()
|
||||
(self.root / f"watch.json.{os.getpid()}").symlink_to(victim)
|
||||
watch.write_json(self.root / "watch.json", {"stage": "ok"})
|
||||
self.assertEqual(victim.read_text(encoding="utf-8"), "keep")
|
||||
self.assertFalse((self.root / "watch.json").is_symlink())
|
||||
self.assertEqual(json.loads((self.root / "watch.json").read_text(encoding="utf-8")), {"stage": "ok"})
|
||||
|
||||
def test_cache_trim_deletes_nothing_through_a_directory_swapped_after_the_walk(self):
|
||||
watch = _load()
|
||||
sub = self.cache / "sub"
|
||||
sub.mkdir()
|
||||
(sub / "f").write_bytes(b"x" * 4096)
|
||||
_, victims = self._victims()
|
||||
(victims / "f").write_bytes(b"v")
|
||||
walk = watch.cache_files
|
||||
|
||||
def swapped(dirs: list[str]) -> list:
|
||||
files = walk(dirs)
|
||||
sub.rename(self.cache / "moved")
|
||||
sub.symlink_to(victims)
|
||||
return files
|
||||
|
||||
watch.cache_files = swapped
|
||||
self.cfg["cache_budget_percent"] = 0
|
||||
watch.trim_caches(self.cfg)
|
||||
self.assertEqual(os.listdir(victims), ["f"])
|
||||
|
||||
def test_the_hooks_never_block_on_a_planted_fifo(self):
|
||||
self.root.mkdir()
|
||||
for name in ("watch.json", "radio-silence.json", "alerts.jsonl"):
|
||||
os.mkfifo(self.root / name, 0o600)
|
||||
env = {
|
||||
"PATH": str(self.base),
|
||||
"HOME": str(self.base),
|
||||
"AGENTS_COUNCIL_ROOT": str(self.root),
|
||||
"AGENTS_COUNCIL_CONFIG": str(self.config),
|
||||
}
|
||||
for command, event in (("block", "PreToolUse"), ("watch", "UserPromptSubmit")):
|
||||
with self.subTest(command=command):
|
||||
payload = {"session_id": "s1", "hook_event_name": event, "tool_input": {"command": "ls"}}
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(WATCH), command],
|
||||
capture_output=True, text=True, env=env, input=json.dumps(payload), check=False, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_the_watch_hook_skips_an_alert_line_that_is_no_alert(self):
|
||||
self.root.mkdir()
|
||||
(self.root / "alerts.jsonl").write_text(
|
||||
"not json\n[1]\n" + json.dumps({"id": "x", "text": 1}) + "\n" + "[" * 200000 + "\n", encoding="utf-8"
|
||||
)
|
||||
result = self._warn_hook("s1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("RESOURCE WARN", result.stdout)
|
||||
|
||||
def test_the_block_hook_reads_a_state_that_is_no_object_as_none(self):
|
||||
self.root.mkdir()
|
||||
(self.root / "watch.json").write_text("[]", encoding="utf-8")
|
||||
(self.root / "radio-silence.json").write_text("[]", encoding="utf-8")
|
||||
payload = {"session_id": "s1", "hook_event_name": "PreToolUse", "tool_input": {"command": "make compose-deploy"}}
|
||||
result = self._run("block", stdin=json.dumps(payload))
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
|
||||
def test_cache_trim_counts_a_hard_linked_file_once(self):
|
||||
watch = _load()
|
||||
(self.cache / "big").write_bytes(b"x" * 65536)
|
||||
for index in range(100):
|
||||
os.link(self.cache / "big", self.cache / f"link{index}")
|
||||
kept = self.cache / "kept"
|
||||
kept.write_bytes(b"x" * 4096)
|
||||
os.utime(kept, (0, 0))
|
||||
self.cfg["cache_budget_percent"] = 100 * 1e6 / watch.disk(self.cfg)[1] / watch.GB
|
||||
watch.trim_caches(self.cfg)
|
||||
self.assertTrue(kept.exists())
|
||||
|
||||
def test_the_watch_hook_reads_no_alert_through_a_planted_name(self):
|
||||
outside = self.base / "outside.jsonl"
|
||||
outside.write_text(json.dumps({"id": time.time_ns(), "stage": "warn", "text": "OUTSIDE"}) + "\n", encoding="utf-8")
|
||||
self.root.mkdir()
|
||||
(self.root / "alerts.jsonl").symlink_to(outside)
|
||||
result = self._warn_hook("s1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertNotIn("OUTSIDE", result.stdout)
|
||||
|
||||
def test_the_watch_hook_keeps_no_history_row_read_through_a_planted_name(self):
|
||||
outside = self.base / "outside.jsonl"
|
||||
outside.write_text(json.dumps({"id": "outside"}) + "\n", encoding="utf-8")
|
||||
self.root.mkdir()
|
||||
(self.root / "queue-history.jsonl").symlink_to(outside)
|
||||
result = self._warn_hook("s1")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
kept = self.base / ".local" / "state" / "agents-council" / "queue-history.jsonl"
|
||||
self.assertNotIn("outside", kept.read_text(encoding="utf-8") if kept.exists() else "")
|
||||
|
||||
def test_cache_trim_counts_the_blocks_a_sparse_file_holds(self):
|
||||
watch = _load()
|
||||
with (self.cache / "sparse").open("wb") as handle:
|
||||
handle.truncate(10**12)
|
||||
kept = self.cache / "kept"
|
||||
kept.write_bytes(b"x" * 4096)
|
||||
os.utime(kept, (0, 0))
|
||||
watch.trim_caches(self.cfg)
|
||||
self.assertTrue(kept.exists())
|
||||
|
||||
def test_cleanup_survives_a_stale_lock_link_and_removes_nothing(self):
|
||||
_, victims = self._victims()
|
||||
self.root.mkdir()
|
||||
lock = self.root / "cleanup.lock"
|
||||
lock.symlink_to(victims)
|
||||
os.utime(victims, (0, 0))
|
||||
os.utime(lock, (0, 0), follow_symlinks=False)
|
||||
result = self._run("cleanup", "--caches")
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(victims.is_dir())
|
||||
self.assertTrue(lock.is_symlink())
|
||||
|
||||
def test_without_config_every_command_is_a_no_op(self):
|
||||
self.config.unlink()
|
||||
result = self._run("status")
|
||||
|
||||
Reference in new issue
Block a user