Files
skills/tests/test_council.py
T
Kevin Veen-BirkenbachandClaude Opus 5.5 cb815216d2 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>
2026-11-06 13:26:59 +00:00

156 lines
6.8 KiB
Python

"""Exercise the kvb-org-organize council script: election, staleness, release, list, gate."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
COUNCIL = REPO_ROOT / "skills" / "kvb-org-organize" / "scripts" / "council.py"
class TestCouncil(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.home = Path(self.tmp.name) / "home"
self.root = Path(self.tmp.name) / "council"
self.sessions = self.home / ".claude" / "sessions"
self.sessions.mkdir(parents=True)
self._live("s1", "One")
self._live("s2", "Two")
def _live(self, sid: str, name: str) -> None:
(self.sessions / f"{sid}.json").write_text(
json.dumps({"sessionId": sid, "name": name}), encoding="utf-8"
)
def _run(self, sid: str, *args: str, stdin: str = "") -> subprocess.CompletedProcess:
env = {
"PATH": "/usr/bin:/bin",
"HOME": str(self.home),
"AGENTS_COUNCIL_ROOT": str(self.root),
"CLAUDE_CODE_SESSION_ID": sid,
}
return subprocess.run(
[sys.executable, str(COUNCIL), *args],
capture_output=True,
text=True,
env=env,
input=stdin,
check=False,
)
def test_first_claim_wins_and_second_is_refused(self):
self.assertEqual(self._run("s1", "claim", "work/x", "--work", "w").returncode, 0)
second = self._run("s2", "claim", "work/x", "--work", "w")
self.assertEqual(second.returncode, 3)
self.assertIn('"session": "s1"', second.stdout)
def test_dead_holder_is_stale_and_can_be_taken_over(self):
self._run("s1", "claim", "work/x", "--work", "w")
(self.sessions / "s1.json").unlink()
self.assertEqual(self._run("s2", "claim", "work/x", "--work", "w").returncode, 4)
self.assertEqual(self._run("s2", "takeover", "work/x", "--work", "w").returncode, 0)
self.assertIn('"session": "s2"', (self.root / "work/x/claim.json").read_text())
def test_takeover_of_a_live_holder_is_refused(self):
self._run("s1", "claim", "work/x", "--work", "w")
self.assertEqual(self._run("s2", "takeover", "work/x", "--work", "w").returncode, 3)
def test_session_flag_cannot_hide_the_claude_code_session(self):
self._run("s1", "claim", "work/x", "--work", "w", "--session", "One")
self.assertIn('"session": "s1"', (self.root / "work/x/claim.json").read_text())
self.assertEqual(self._run("s1", "release", "work/x", "--session", "One").returncode, 0)
def test_only_the_holder_releases(self):
self._run("s1", "claim", "work/x", "--work", "w")
self.assertEqual(self._run("s2", "release", "work/x").returncode, 3)
self.assertEqual(self._run("s1", "release", "work/x").returncode, 0)
self.assertFalse((self.root / "work/x").exists())
def test_gate_blocks_unclaimed_edits_in_a_git_worktree_only(self):
repo = Path(self.tmp.name) / "repo"
(repo / ".git").mkdir(parents=True)
inside = json.dumps({"session_id": "s1", "tool_input": {"file_path": str(repo / "f")}})
outside = json.dumps(
{"session_id": "s1", "tool_input": {"file_path": str(self.home / "f")}}
)
self.assertEqual(self._run("s1", "gate", stdin=inside).returncode, 2)
self.assertEqual(self._run("s1", "gate", stdin=outside).returncode, 0)
self._run("s1", "claim", "work/x", "--work", "w")
self.assertEqual(self._run("s1", "gate", stdin=inside).returncode, 0)
def test_gate_still_blocks_past_a_planted_fifo_or_malformed_claim(self):
repo = Path(self.tmp.name) / "repo"
(repo / ".git").mkdir(parents=True)
inside = json.dumps({"session_id": "s1", "tool_input": {"file_path": str(repo / "f")}})
(self.root / "work" / "fifo").mkdir(parents=True)
os.mkfifo(self.root / "work" / "fifo" / "claim.json", 0o600)
for claim in ({}, [], {"session": "s2", "runtime": "claude-code"}):
with self.subTest(claim=claim):
(self.root / "work" / "bad").mkdir(exist_ok=True)
(self.root / "work" / "bad" / "claim.json").write_text(json.dumps(claim), encoding="utf-8")
env = {"PATH": "/usr/bin:/bin", "HOME": str(self.home), "AGENTS_COUNCIL_ROOT": str(self.root)}
result = subprocess.run(
[sys.executable, str(COUNCIL), "gate"],
capture_output=True, text=True, env=env, input=inside, check=False, timeout=30,
)
self.assertEqual(result.returncode, 2, result.stderr)
def _two_claims(self) -> str:
work = "fix the failing deploy of web-app-x on branch fix/web-app-x/deploy"
self._run("s1", "claim", "work/x", "--work", work)
self._run("s2", "claim", "work/y", "--work", "short work")
(self.sessions / "s2.json").unlink()
return work
def _since(self, office: str) -> str:
return json.loads((self.root / office / "claim.json").read_text(encoding="utf-8"))["since"]
def test_list_prints_office_holder_state_and_a_short_work_cell(self):
self._two_claims()
result = self._run("s1", "list")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(
result.stdout.splitlines(),
["work/x\tOne\tlive\tfix the failing deploy of web-app-x on …", "work/y\tTwo\tstale\tshort work"],
)
def test_list_collapses_whitespace_in_the_short_work_cell(self):
self._run("s1", "claim", "work/z", "--work", "a\tb\nc")
self.assertEqual(self._run("s1", "list").stdout.splitlines(), ["work/z\tOne\tlive\ta b c"])
def test_list_long_prints_since_and_the_whole_work_line(self):
work = self._two_claims()
self.assertEqual(
self._run("s1", "list", "--long").stdout.splitlines(),
[
f"work/x\tOne\t{self._since('work/x')}\tlive\t{work}",
f"work/y\tTwo\t{self._since('work/y')}\tstale\tshort work",
],
)
def test_list_mine_keeps_only_the_callers_claims_in_both_forms(self):
work = self._two_claims()
self.assertEqual(
self._run("s1", "list", "--mine").stdout.splitlines(),
["work/x\tOne\tlive\tfix the failing deploy of web-app-x on …"],
)
self.assertEqual(
self._run("s1", "list", "--mine", "--long").stdout.splitlines(),
[f"work/x\tOne\t{self._since('work/x')}\tlive\t{work}"],
)
def test_invalid_office_is_a_usage_error(self):
self.assertEqual(self._run("s1", "claim", "../x", "--work", "w").returncode, 2)
if __name__ == "__main__":
unittest.main()