Runs: check file writes by real path and log every refused tool

Write(clients/**) rules alone let a competitor scan's report write be denied.
Writes inside clients/, team/ and reports/ are now allowed by resolved path;
anything refused is logged with the tool and path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-10-27 22:17:43 -04:00
parent 2e0b28f2a2
commit c8ed9870d0

View File

@ -55,6 +55,24 @@ const BLOCKED_TOOLS = [
"approve_scheduled", "cancel_scheduled", "schedule_from_library", "competitor_add", "comment_inbox", "direct_messages", "list_scheduled", "approve_scheduled", "cancel_scheduled", "schedule_from_library", "competitor_add", "comment_inbox", "direct_messages", "list_scheduled",
].map((t) => `mcp__social-manager__${t}`); ].map((t) => `mcp__social-manager__${t}`);
// Path-scoped rules like Write(clients/**) can miss (absolute paths, other spellings), so file writes are also
// checked here by real path. Everything else not on the allow-list is refused, with the reason in the job log.
const WRITE_DIRS = ["clients", "team", "reports"].map((d) => path.resolve(PROJECT_ROOT, d));
const FILE_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
function permissionCheck(job) {
return async (tool, input) => {
const file = input.file_path || input.notebook_path;
if (FILE_TOOLS.has(tool) && file) {
const full = path.resolve(PROJECT_ROOT, file);
if (WRITE_DIRS.some((d) => full === d || full.startsWith(d + path.sep))) return { behavior: "allow", updatedInput: input };
log(job, "warn", `Refused ${tool} outside clients/, team/ and reports/: ${file}`);
return { behavior: "deny", message: `Only files inside clients/, team/ and reports/ can be written. Write to clients/<slug>/ instead.` };
}
log(job, "warn", `Refused tool not allowed in runs: ${tool}`);
return { behavior: "deny", message: `${tool} isn't available in dashboard runs.` };
};
}
// Our own model servers (Ollama on a laptop) break on very long requests, so those runs send only what the job needs: // Our own model servers (Ollama on a laptop) break on very long requests, so those runs send only what the job needs:
// the built-in tools runs use, the job's own skill and the social-manager server (≈13k tokens instead of ≈25k). // the built-in tools runs use, the job's own skill and the social-manager server (≈13k tokens instead of ≈25k).
const LEAN_TOOLS = ["Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill", "TodoWrite"]; const LEAN_TOOLS = ["Read", "Glob", "Grep", "Edit", "Write", "Bash", "Skill", "TodoWrite"];
@ -189,8 +207,9 @@ async function run(job) {
model: AI.model, model: AI.model,
maxTurns: AI.maxTurns, maxTurns: AI.maxTurns,
env: runEnv(), env: runEnv(),
permissionMode: "dontAsk", permissionMode: "default",
allowedTools: READ_TOOLS, allowedTools: READ_TOOLS,
canUseTool: permissionCheck(job),
disallowedTools: BLOCKED_TOOLS, disallowedTools: BLOCKED_TOOLS,
abortController: job.abort, abortController: job.abort,
systemPrompt: { type: "preset", preset: "claude_code", append: HEADLESS_RULES }, systemPrompt: { type: "preset", preset: "claude_code", append: HEADLESS_RULES },
@ -205,6 +224,7 @@ async function run(job) {
} else if (msg.type === "result") { } else if (msg.type === "result") {
job.costUsd = msg.total_cost_usd ?? null; job.costUsd = msg.total_cost_usd ?? null;
job.turns = msg.num_turns ?? null; job.turns = msg.num_turns ?? null;
for (const d of msg.permission_denials || []) log(job, "warn", `Denied: ${d.tool_name} ${JSON.stringify(d.tool_input).slice(0, 200)}`);
if (msg.subtype === "success" && !msg.is_error) { if (msg.subtype === "success" && !msg.is_error) {
job.result = msg.result; job.result = msg.result;
job.status = "succeeded"; job.status = "succeeded";