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:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-06 13:26:59 +00:00
1 parent 6985c3a27e
commit 3843821674
11 files changed
+414 -54

No files matched your search

+7 -2
View File
@@ -60,6 +60,10 @@ 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 COUNCIL_CONFIG_DENY = "Edit(~/.claude/agents-council/**)";
data.permissions = data.permissions || {};
data.permissions.deny = [...new Set([...(data.permissions.deny || []), COUNCIL_CONFIG_DENY])];
const pluginRepos = new Set(Object.values(MARKETPLACES).map((entry) => entry.source.repo));
const lock = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "skills-lock.json"), "utf8"));
@@ -87,6 +91,7 @@ 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} `);
}
}
}
@@ -105,7 +110,7 @@ if (!JSON.stringify(data.hooks.PreToolUse).includes("kvb-org-organize/scripts/co
const council = path.join(path.dirname(settingsPath), "skills", "kvb-org-organize", "scripts", "council.py");
data.hooks.PreToolUse.push({
matcher: "Edit|Write|MultiEdit|NotebookEdit",
hooks: [{ type: "command", command: `python3 ${JSON.stringify(council)} gate` }],
hooks: [{ type: "command", command: `${PYTHON} ${JSON.stringify(council)} gate` }],
});
}
@@ -128,7 +133,7 @@ for (const [script, event, matcher, sub, extra] of SCRIPT_HOOKS) {
if (registered) continue;
const file = JSON.stringify(path.join(path.dirname(settingsPath), "skills", ...script.split("/")));
// Trip-wire: python3 on a missing file exits 2, which blocks every prompt or tool call; the test makes it exit 1.
const command = `[ -f ${file} ] && python3 ${file} ${sub}`;
const command = `[ -f ${file} ] && ${PYTHON} ${file} ${sub}`;
const entry = { hooks: [{ type: "command", command, ...extra }] };
data.hooks[event].push(matcher ? { matcher, ...entry } : entry);
}