fix(kvb-org-organize): harden the council hooks against sandboxed agents
- Move the council config to ~/.claude/agents-council/config.json, which the sandbox cannot write; the installer adds Edit(~/.claude/agents-council/**) to permissions.deny. - Run every kvb hook as /usr/bin/python3 -I and rewrite old bare python3 entries. - Refuse a council root that is a symlink, another uid's, or group or other writable, in watch, queue and radio. - Run worktree_cleanup entries as argv without a shell in /, only for compose project names. - queue block: tokenize every Bash command like bash (quotes, word-start comments, line continuations), and hold each held command to the Bash deny and ask rules of the managed, user and project settings; refuse a hold it cannot parse. - radio: deliver fields on one line of at most 1000 characters, at most 10 rows. Operator step after install: move ~/.config/agents-council/config.json to ~/.claude/agents-council/config.json. MEDUSA acceptances (kvb-think-criticize 2026-11-06, skeptics A and B): ACP-SCAN-028 load_config (config outside the sandbox, Docker residual 093); CODEGEN-SCAN-004 and LLM06 run_cleanup and worktrees_command shell strings (operator config only); SAST-PATH-001 queue hold Popen (the caller's own command in its sandbox); PA-133 radio hook context (peer rows, clipped); JB-SCAN-641/616/2979 clip and MM-SCAN-058 replace (name matches). 🎯 Fix: 90 % 💥 Break: 5 % 💸 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:
1 parent
6985c3a27e
commit
3843821674
11 files changed
+414
-54
No files matched your search
+37
-12
@@ -199,17 +199,23 @@ class TestWatchCommands(unittest.TestCase):
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(new.exists())
|
||||
|
||||
def test_full_cleanup_reaches_only_marked_running_worktrees(self):
|
||||
marked, foreign = self.base / "marked", self.base / "foreign"
|
||||
for tree in (marked, foreign):
|
||||
def _mark_cleaned(self) -> list[str]:
|
||||
return [sys.executable, "-c", "import sys; open(sys.argv[1], 'a').write('x')", str(self.base / "cleaned-{project}")]
|
||||
|
||||
def test_full_cleanup_reaches_only_marked_running_worktrees_with_a_compose_project(self):
|
||||
marked, foreign, odd = self.base / "marked", self.base / "foreign", self.base / "odd"
|
||||
for tree in (marked, foreign, odd):
|
||||
tree.mkdir()
|
||||
(marked / "Makefile").write_text("", encoding="utf-8")
|
||||
self.cfg["worktrees_command"] = f"echo {marked}; echo {foreign}; echo {marked}"
|
||||
self.cfg["worktree_cleanup"] = ["echo x >> {worktree}/cleaned"]
|
||||
for tree in (marked, odd):
|
||||
(tree / "Makefile").write_text("", encoding="utf-8")
|
||||
self.cfg["worktrees_command"] = (
|
||||
f"printf '%s\\t%s\\n' {marked} stack-a {foreign} stack-b {marked} stack-a {odd} 'x;touch y' {odd}"
|
||||
)
|
||||
self.cfg["worktree_cleanup"] = [self._mark_cleaned()]
|
||||
self._write_config()
|
||||
self.assertEqual(self._run("cleanup", "--full").returncode, 0)
|
||||
self.assertEqual((marked / "cleaned").read_text(encoding="utf-8"), "x\n")
|
||||
self.assertFalse((foreign / "cleaned").exists())
|
||||
self.assertEqual(sorted(p.name for p in self.base.glob("cleaned-*")), ["cleaned-stack-a"])
|
||||
self.assertEqual((self.base / "cleaned-stack-a").read_text(encoding="utf-8"), "x")
|
||||
|
||||
def test_full_cleanup_skips_a_worktree_where_a_busy_process_runs(self):
|
||||
idle, gated = self.base / "idle", self.base / "gated"
|
||||
@@ -219,12 +225,31 @@ class TestWatchCommands(unittest.TestCase):
|
||||
gate = subprocess.Popen(["sleep", "60"], cwd=gated / "sub")
|
||||
self.addCleanup(gate.wait)
|
||||
self.addCleanup(gate.kill)
|
||||
self.cfg["worktrees_command"] = f"echo {idle}; echo {gated}"
|
||||
self.cfg["worktree_cleanup"] = ["echo x >> {worktree}/cleaned"]
|
||||
self.cfg["worktrees_command"] = f"printf '%s\\t%s\\n' {idle} idle {gated} gated"
|
||||
self.cfg["worktree_cleanup"] = [self._mark_cleaned()]
|
||||
self._write_config()
|
||||
self.assertEqual(self._run("cleanup", "--full").returncode, 0)
|
||||
self.assertTrue((idle / "cleaned").exists())
|
||||
self.assertFalse((gated / "cleaned").exists())
|
||||
self.assertTrue((self.base / "cleaned-idle").exists())
|
||||
self.assertFalse((self.base / "cleaned-gated").exists())
|
||||
|
||||
def test_a_group_writable_council_root_stops_commands_and_silences_hooks(self):
|
||||
self.root.mkdir()
|
||||
self.root.chmod(0o777)
|
||||
result = self._run("status")
|
||||
self.assertEqual(result.returncode, 2)
|
||||
self.assertIn("cannot write", result.stderr)
|
||||
for hook in ("watch", "block"):
|
||||
with self.subTest(hook=hook):
|
||||
quiet = self._run(hook, stdin=self._bash("make compose-deploy"))
|
||||
self.assertEqual(quiet.returncode, 0)
|
||||
self.assertIn("council hooks stay off", quiet.stdout)
|
||||
self.assertFalse((self.root / "watch.json").exists())
|
||||
|
||||
def test_a_symlinked_council_root_stops_every_command(self):
|
||||
target = self.base / "elsewhere"
|
||||
target.mkdir()
|
||||
self.root.symlink_to(target)
|
||||
self.assertEqual(self._run("status").returncode, 2)
|
||||
|
||||
def test_without_config_every_command_is_a_no_op(self):
|
||||
self.config.unlink()
|
||||
|
||||
Reference in new issue
Block a user