Implement granular tool permissions (#82)

* Granular tool permissions

* Fix build

* Start on UI

* Fixes

* Fmt

* Go ham on UI

* Update migrations

* Considate wiki files

* Clean up

* More tweaks. Docs.

* Consolidate collab and noncollab

* Fix build

* Restrict for non-collaborators
This commit is contained in:
Colin McDonnell
2026-01-15 08:05:30 +00:00
committed by pullfrog[bot]
parent 4547b0032e
commit 97dce099c1
29 changed files with 800 additions and 2052 deletions
+140 -132
View File
@@ -100281,7 +100281,7 @@ function getModes({ disableProgressComment }) {
`
},
{
name: "Address Reviews",
name: "AddressReviews",
description: "Address PR review feedback; respond to reviewer comments; make requested changes to an existing PR",
prompt: `Follow these steps. THINK HARDER.
1. Checkout the PR using ${ghPullfrogMcpName}/checkout_pr with the PR number. This fetches the PR branch and configures push settings (including for fork PRs).
@@ -100406,8 +100406,21 @@ function buildRuntimeContext(repo) {
}
return lines.join("\n");
}
var addInstructions = ({ payload, repo }) => {
const useNativeBash = !repo.isPublic;
function getShellInstructions(bash) {
switch (bash) {
case "disabled":
return `**Shell commands**: Shell command execution is DISABLED. Do not attempt to run shell commands.`;
case "restricted":
return `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`;
case "enabled":
return `**Shell commands**: Use your native bash/shell tool for shell command execution.`;
default: {
const _exhaustive = bash;
return _exhaustive;
}
}
}
var addInstructions = ({ payload, repo, tools }) => {
let encodedEvent = "";
const eventKeys = Object.keys(payload.event);
if (eventKeys.length === 1 && eventKeys[0] === "trigger") {
@@ -100462,7 +100475,7 @@ Tool names may be formatted as \`(server name)/(tool name)\`, for example: \`${g
**Efficiency**: Trust the tools - do not repeatedly verify file contents or git status after operations. If a tool reports success, proceed to the next step. Only verify if you encounter an actual error.
${useNativeBash ? `**Shell commands**: Use your native bash/shell tool for shell command execution.` : `**Shell commands**: Use the \`${ghPullfrogMcpName}/bash\` MCP tool for all shell command execution. This tool provides a secure environment with filtered credentials. Do NOT use any native shell/bash tool - it is disabled for security.`}
${getShellInstructions(tools.bash)}
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
@@ -104622,6 +104635,14 @@ var claudeEffortModels = {
auto: "opusplan",
max: "opus"
};
function buildDisallowedTools(tools) {
const disallowed = [];
if (tools.web === "disabled") disallowed.push("WebFetch");
if (tools.search === "disabled") disallowed.push("WebSearch");
if (tools.write === "disabled") disallowed.push("Write");
if (tools.bash !== "enabled") disallowed.push("Bash");
return disallowed;
}
var claude = agent({
name: "claude",
install: async () => {
@@ -104632,30 +104653,19 @@ var claude = agent({
executablePath: "cli.js"
});
},
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => {
delete process.env.ANTHROPIC_API_KEY;
const prompt = addInstructions({ payload, repo });
const prompt = addInstructions({ payload, repo, tools });
log.group("Full prompt", () => log.info(prompt));
const model = claudeEffortModels[effort];
log.info(`Using model: ${model} (effort: ${effort})`);
const disallowedTools = repo.isPublic ? ["Bash"] : [];
const sandboxOptions = payload.sandbox ? {
permissionMode: "default",
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
async canUseTool(toolName, input, _options) {
if (toolName.startsWith("mcp__gh_pullfrog__"))
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
}
} : {
permissionMode: "bypassPermissions",
disallowedTools
};
if (payload.sandbox) {
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
const disallowedTools = buildDisallowedTools(tools);
if (disallowedTools.length > 0) {
log.info(`\u{1F512} disallowed tools: ${disallowedTools.join(", ")}`);
}
const queryOptions = {
...sandboxOptions,
permissionMode: "bypassPermissions",
disallowedTools,
mcpServers,
model,
pathToClaudeCodeExecutable: cliPath,
@@ -105122,32 +105132,35 @@ var codexReasoningEffort = {
// use default
max: "high"
};
function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }) {
function writeCodexConfig({ tempHome, mcpServers, tools }) {
const codexDir = join6(tempHome, ".codex");
mkdirSync3(codexDir, { recursive: true });
const configPath = join6(codexDir, "config.toml");
const mcpServerSections = [];
for (const [name, config4] of Object.entries(mcpServers)) {
if (config4.type !== "http") continue;
log.info(`\xBB Adding MCP server '${name}' at ${config4.url}`);
log.info(`\xBB adding MCP server '${name}' at ${config4.url}`);
mcpServerSections.push(`[mcp_servers.${name}]
url = "${config4.url}"`);
}
const shellPolicy = isPublicRepo ? `[shell_environment_policy]
ignore_default_excludes = false` : "";
const features = [];
if (tools.bash !== "enabled") {
features.push("shell_command_tool = false");
features.push("unified_exec = false");
}
const featuresSection = features.length > 0 ? `[features]
${features.join("\n")}` : "";
writeFileSync(
configPath,
`# written by pullfrog
${shellPolicy}
${featuresSection}
${mcpServerSections.join("\n\n")}
`.trim() + "\n"
);
if (isPublicRepo) {
log.info(`\xBB Codex config written to ${configPath} (env filtering: enabled)`);
} else {
log.info(`\xBB Codex config written to ${configPath} (private repo: no env filtering)`);
}
log.info(
`\xBB Codex config written to ${configPath} (shell: ${tools.bash === "enabled" ? "enabled" : "disabled"})`
);
return codexDir;
}
var codex = agent({
@@ -105159,14 +105172,14 @@ var codex = agent({
executablePath: "bin/codex.js"
});
},
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => {
const tempHome = process.env.PULLFROG_TEMP_DIR;
const configDir = join6(tempHome, ".config", "codex");
mkdirSync3(configDir, { recursive: true });
const codexDir = writeCodexConfig({
tempHome,
mcpServers,
isPublicRepo: repo.isPublic
tools
});
setupProcessAgentEnv({
OPENAI_API_KEY: apiKey,
@@ -105184,26 +105197,24 @@ var codex = agent({
apiKey,
codexPathOverride: cliPath
};
if (payload.sandbox) {
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
}
const codex2 = new Codex(codexOptions);
const baseThreadOptions = payload.sandbox ? {
const threadOptions = {
model,
approvalPolicy: "never",
sandboxMode: "read-only",
networkAccessEnabled: false
} : {
model,
approvalPolicy: "never",
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
sandboxMode: "danger-full-access",
networkAccessEnabled: true
// write: "disabled" → read-only sandbox, otherwise full access for git ops
sandboxMode: tools.write === "disabled" ? "read-only" : "danger-full-access",
// web: controls network access
networkAccessEnabled: tools.web !== "disabled",
// search: controls web search
webSearchEnabled: tools.search !== "disabled",
...modelReasoningEffort && { modelReasoningEffort }
};
const threadOptions = modelReasoningEffort ? { ...baseThreadOptions, modelReasoningEffort } : baseThreadOptions;
log.info(
`\u{1F527} Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}`
);
const thread = codex2.startThread(threadOptions);
try {
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, tools }));
let finalOutput2 = "";
for await (const event of streamedTurn.events) {
const handler2 = messageHandlers2[event.type];
@@ -105330,9 +105341,9 @@ var cursor = agent({
executableName: "cursor-agent"
});
},
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort }) => {
run: async ({ payload, apiKey, cliPath, mcpServers, repo, effort, tools }) => {
configureCursorMcpServers({ mcpServers, cliPath });
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
configureCursorTools({ tools });
const projectCliConfigPath = join7(process.cwd(), ".cursor", "cli.json");
let modelOverride = null;
if (existsSync4(projectCliConfigPath)) {
@@ -105404,16 +105415,13 @@ var cursor = agent({
}
};
try {
const fullPrompt = addInstructions({ payload, repo });
const fullPrompt = addInstructions({ payload, repo, tools });
log.group("Full prompt", () => log.info(fullPrompt));
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
if (modelOverride) {
baseArgs.push("--model", modelOverride);
}
const cursorArgs = payload.sandbox ? baseArgs : [...baseArgs, "--force"];
if (payload.sandbox) {
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
}
const cursorArgs = [...baseArgs, "--force"];
log.info("Running Cursor CLI...");
const startTime = Date.now();
return new Promise((resolve2) => {
@@ -105515,29 +105523,32 @@ function configureCursorMcpServers({ mcpServers }) {
writeFileSync2(mcpConfigPath, JSON.stringify({ mcpServers: cursorMcpServers }, null, 2), "utf-8");
log.info(`\xBB MCP config written to ${mcpConfigPath}`);
}
function configureCursorSandbox({
sandbox,
isPublicRepo
}) {
function configureCursorTools({ tools }) {
const realHome = homedir2();
const cursorConfigDir = join7(realHome, ".config", "cursor");
const cliConfigPath = join7(cursorConfigDir, "cli-config.json");
mkdirSync4(cursorConfigDir, { recursive: true });
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
const config4 = sandbox ? {
const deny = [];
if (tools.search === "disabled") deny.push("WebSearch");
if (tools.write === "disabled") deny.push("Write(**)");
if (tools.bash !== "enabled") deny.push("Shell(*)");
const needsSandbox = tools.web === "disabled";
const config4 = {
permissions: {
allow: ["Read(**)"],
deny: ["Write(**)", ...denyShell]
}
} : {
permissions: {
allow: ["Read(**)", "Write(**)"],
deny: denyShell
allow: tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"],
deny
}
};
if (needsSandbox) {
config4.sandbox = {
mode: "enabled",
networkAccess: "allowlist"
};
}
writeFileSync2(cliConfigPath, JSON.stringify(config4, null, 2), "utf-8");
log.info(`\xBB CLI config written to ${cliConfigPath}`);
log.info(
`\xBB CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
`\u{1F527} Cursor permissions: allow=${config4.permissions.allow.join(",")}, deny=${deny.join(",") || "(none)"}, sandbox=${needsSandbox ? "enabled" : "disabled"}`
);
}
@@ -105714,37 +105725,16 @@ var gemini = agent({
...githubInstallationToken2 && { githubInstallationToken: githubInstallationToken2 }
});
},
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort, tools }) => {
const { model, thinkingLevel } = geminiEffortConfig[effort];
log.info(`Using model: ${model}, thinkingLevel: ${thinkingLevel}`);
configureGeminiSettings({ mcpServers, isPublicRepo: repo.isPublic, thinkingLevel });
configureGeminiSettings({ mcpServers, tools, thinkingLevel });
if (!apiKey) {
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
}
const sessionPrompt = addInstructions({ payload, repo });
const sessionPrompt = addInstructions({ payload, repo, tools });
log.group("Full prompt", () => log.info(sessionPrompt));
let args3;
if (payload.sandbox) {
args3 = [
"--model",
model,
"--allowed-tools",
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
"--allowed-mcp-server-names",
"gh_pullfrog",
"--output-format=stream-json",
"-p",
sessionPrompt
];
} else {
args3 = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
if (repo.isPublic) {
log.info("\u{1F512} public repo: native shell disabled via excludeTools, using MCP bash");
}
}
if (payload.sandbox) {
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
}
const args3 = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
let finalOutput2 = "";
let stdoutBuffer = "";
try {
@@ -105810,7 +105800,7 @@ var gemini = agent({
});
function configureGeminiSettings({
mcpServers,
isPublicRepo,
tools,
thinkingLevel
}) {
const realHome = homedir3();
@@ -105835,8 +105825,13 @@ function configureGeminiSettings({
trust: true
// trust our own MCP server to avoid confirmation prompts
};
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
log.info(`adding MCP server '${serverName}' at ${serverConfig.url}...`);
}
const exclude = [];
if (tools.bash !== "enabled") exclude.push("run_shell_command");
if (tools.write === "disabled") exclude.push("write_file");
if (tools.web === "disabled") exclude.push("web_fetch");
if (tools.search === "disabled") exclude.push("google_web_search");
const newSettings = {
...existingSettings,
mcpServers: geminiMcpServers,
@@ -105848,13 +105843,15 @@ function configureGeminiSettings({
thinkingLevel
}
}
}
},
// v0.3.0+ nested format
...exclude.length > 0 && { tools: { exclude } }
};
if (isPublicRepo) {
newSettings.excludeTools = ["run_shell_command"];
}
writeFileSync3(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
log.info(`\xBB Gemini settings written to ${settingsPath}`);
if (exclude.length > 0) {
log.info(`\u{1F512} excluded tools: ${exclude.join(", ")}`);
}
}
// agents/opencode.ts
@@ -105877,22 +105874,16 @@ var opencode = agent({
mcpServers,
cliPath,
repo,
effort: _effort
effort: _effort,
tools
}) => {
const tempHome = process.env.PULLFROG_TEMP_DIR;
const configDir = join9(tempHome, ".config", "opencode");
mkdirSync6(configDir, { recursive: true });
configureOpenCode({
mcpServers,
sandbox: payload.sandbox ?? false,
isPublicRepo: repo.isPublic
});
const prompt = addInstructions({ payload, repo });
configureOpenCode({ mcpServers, tools });
const prompt = addInstructions({ payload, repo, tools });
log.group("Full prompt", () => log.info(prompt));
const args3 = ["run", prompt, "--format", "json"];
if (payload.sandbox) {
log.info("\u{1F512} sandbox mode enabled: restricting to read-only operations");
}
setupProcessAgentEnv({ HOME: tempHome });
const env3 = {
...createAgentEnv({ HOME: tempHome }),
@@ -106002,7 +105993,7 @@ var opencode = agent({
};
}
});
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) {
function configureOpenCode({ mcpServers, tools }) {
const tempHome = process.env.PULLFROG_TEMP_DIR;
const configDir = join9(tempHome, ".config", "opencode");
mkdirSync6(configDir, { recursive: true });
@@ -106022,17 +106013,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) {
url: serverConfig.url
};
}
const bashPermission = isPublicRepo ? "deny" : "allow";
const permission = sandbox ? {
edit: "deny",
bash: "deny",
webfetch: "deny",
doom_loop: "allow",
external_directory: "allow"
} : {
edit: "allow",
bash: bashPermission,
webfetch: "allow",
const permission = {
edit: tools.write === "disabled" ? "deny" : "allow",
bash: tools.bash !== "enabled" ? "deny" : "allow",
webfetch: tools.web === "disabled" ? "deny" : "allow",
doom_loop: "allow",
external_directory: "allow"
};
@@ -106049,7 +106033,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }) {
);
throw error50;
}
log.info(`\xBB OpenCode config written to ${configPath} (sandbox: ${sandbox})`);
log.info(`\xBB OpenCode config written to ${configPath}`);
log.info(
`\u{1F527} OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
);
log.debug(`OpenCode config contents:
${configJson}`);
}
@@ -106212,9 +106199,10 @@ var agents = {
// utils/api.ts
var DEFAULT_REPO_SETTINGS = {
defaultAgent: null,
webAccessLevel: "full_access",
webAccessAllowTrusted: false,
webAccessDomains: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
modes: []
};
async function fetchWorkflowRunInfo(runId) {
@@ -138323,13 +138311,18 @@ var Timer = class {
};
// main.ts
var ToolPermissionInput = type.enumerated("disabled", "enabled");
var BashPermissionInput = type.enumerated("disabled", "restricted", "enabled");
var Inputs = type({
prompt: "string",
"effort?": Effort,
"agent?": AgentName.or("null"),
"event?": "object",
"modes?": ModeSchema.array(),
"sandbox?": "boolean",
"web?": ToolPermissionInput,
"search?": ToolPermissionInput,
"write?": ToolPermissionInput,
"bash?": BashPermissionInput,
"disableProgressComment?": "true",
"comment_id?": "number|null",
"issue_id?": "number|null",
@@ -138597,7 +138590,6 @@ function parsePayload(inputs) {
event: inputs.event,
modes: inputs.modes ?? modes,
effort: inputs.effort ?? "auto",
sandbox: inputs.sandbox,
disableProgressComment: inputs.disableProgressComment,
comment_id: inputs.comment_id,
issue_id: inputs.issue_id,
@@ -138622,8 +138614,7 @@ function parsePayload(inputs) {
trigger: "unknown"
},
modes: inputs.modes ?? modes,
effort: inputs.effort ?? "auto",
sandbox: inputs.sandbox
effort: inputs.effort ?? "auto"
};
}
}
@@ -138668,6 +138659,14 @@ function validateApiKey(params) {
apiKeys
};
}
function computeToolPermissions(params) {
return {
web: params.inputs.web ?? "enabled",
search: params.inputs.search ?? "enabled",
write: params.inputs.write ?? "enabled",
bash: params.inputs.bash ?? (params.isPublicRepo ? "restricted" : "enabled")
};
}
async function runAgent(ctx) {
const effort = ctx.payload.effort ?? "auto";
log.info(`Running ${ctx.agent.name} with effort=${effort}...`);
@@ -138676,6 +138675,10 @@ async function runAgent(ctx) {
${encode(eventWithoutContext)}`;
log.box(promptContent, { title: "Prompt" });
const tools = computeToolPermissions({ inputs: ctx.inputs, isPublicRepo: !ctx.repo.private });
log.info(
`Tool permissions: web=${tools.web}, search=${tools.search}, write=${tools.write}, bash=${tools.bash}`
);
return ctx.agent.run({
payload: ctx.payload,
mcpServers: ctx.mcpServers,
@@ -138688,7 +138691,8 @@ ${encode(eventWithoutContext)}`;
defaultBranch: ctx.repo.default_branch,
isPublic: !ctx.repo.private
},
effort
effort,
tools
});
}
async function handleAgentResult(result) {
@@ -138726,7 +138730,11 @@ async function run() {
agent: payloadObj.agent,
event: payloadObj.event,
modes: payloadObj.modes,
sandbox: payloadObj.sandbox,
// granular tool permissions
web: payloadObj.web,
search: payloadObj.search,
write: payloadObj.write,
bash: payloadObj.bash,
disableProgressComment: payloadObj.disableProgressComment,
comment_id: payloadObj.comment_id,
issue_id: payloadObj.issue_id,
+5 -1
View File
@@ -33,7 +33,11 @@ async function run(): Promise<void> {
agent: payloadObj.agent,
event: payloadObj.event,
modes: payloadObj.modes,
sandbox: payloadObj.sandbox,
// granular tool permissions
web: payloadObj.web,
search: payloadObj.search,
write: payloadObj.write,
bash: payloadObj.bash,
disableProgressComment: payloadObj.disableProgressComment,
comment_id: payloadObj.comment_id,
issue_id: payloadObj.issue_id,