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:
committed by
pullfrog[bot]
parent
4547b0032e
commit
97dce099c1
+23
-25
@@ -3,7 +3,7 @@ import type { Effort } from "../external.ts";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, createAgentEnv, installFromNpmTarball } from "./shared.ts";
|
||||
import { agent, createAgentEnv, installFromNpmTarball, type ToolPermissions } from "./shared.ts";
|
||||
|
||||
// Model selection based on effort level
|
||||
// Note: mini uses Haiku for speed, auto uses opusplan for balance, max uses Opus for capability
|
||||
@@ -19,6 +19,20 @@ const claudeEffortModels: Record<Effort, string> = {
|
||||
// See: https://platform.claude.com/docs/en/build-with-claude/effort
|
||||
// This approach could replace model selection if effort proves effective for controlling capability.
|
||||
|
||||
/**
|
||||
* Build disallowedTools list from ToolPermissions.
|
||||
*/
|
||||
function buildDisallowedTools(tools: ToolPermissions): string[] {
|
||||
const disallowed: string[] = [];
|
||||
if (tools.web === "disabled") disallowed.push("WebFetch");
|
||||
if (tools.search === "disabled") disallowed.push("WebSearch");
|
||||
if (tools.write === "disabled") disallowed.push("Write");
|
||||
// both "disabled" and "restricted" block native bash
|
||||
// "restricted" means use MCP bash tool instead
|
||||
if (tools.bash !== "enabled") disallowed.push("Bash");
|
||||
return disallowed;
|
||||
}
|
||||
|
||||
export const claude = agent({
|
||||
name: "claude",
|
||||
install: async () => {
|
||||
@@ -29,44 +43,28 @@ export const claude = agent({
|
||||
executablePath: "cli.js",
|
||||
});
|
||||
},
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort }) => {
|
||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo, effort, tools }) => {
|
||||
// Ensure API key is NOT in process.env - only pass via SDK's env option
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
const prompt = addInstructions({ payload, repo });
|
||||
const prompt = addInstructions({ payload, repo, tools });
|
||||
log.group("Full prompt", () => log.info(prompt));
|
||||
|
||||
// select model based on effort level
|
||||
const model = claudeEffortModels[effort];
|
||||
log.info(`Using model: ${model} (effort: ${effort})`);
|
||||
|
||||
// SECURITY: For PUBLIC repos, Claude Code spawns subprocesses with full process.env, leaking API keys.
|
||||
// disable native Bash; agents use MCP bash tool which filters secrets.
|
||||
// for private repos, native Bash is allowed since secrets are less exposed.
|
||||
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||
const sandboxOptions: Options = 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" as const,
|
||||
disallowedTools,
|
||||
};
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
// build disallowedTools based on tool permissions
|
||||
const disallowedTools = buildDisallowedTools(tools);
|
||||
if (disallowedTools.length > 0) {
|
||||
log.info(`🔒 disallowed tools: ${disallowedTools.join(", ")}`);
|
||||
}
|
||||
|
||||
// Pass secrets via SDK's env option only (not process.env)
|
||||
// This ensures secrets are only available to Claude Code subprocess, not user code
|
||||
const queryOptions: Options = {
|
||||
...sandboxOptions,
|
||||
permissionMode: "bypassPermissions" as const,
|
||||
disallowedTools,
|
||||
mcpServers,
|
||||
model,
|
||||
pathToClaudeCodeExecutable: cliPath,
|
||||
|
||||
+40
-42
@@ -11,7 +11,12 @@ import {
|
||||
import type { Effort } from "../external.ts";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { addInstructions } from "./instructions.ts";
|
||||
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
|
||||
import {
|
||||
agent,
|
||||
installFromNpmTarball,
|
||||
setupProcessAgentEnv,
|
||||
type ToolPermissions,
|
||||
} from "./shared.ts";
|
||||
|
||||
// model configuration based on effort level
|
||||
const codexModel: Record<Effort, string> = {
|
||||
@@ -33,10 +38,10 @@ const codexReasoningEffort: Record<Effort, ModelReasoningEffort | undefined> = {
|
||||
interface WriteCodexConfigParams {
|
||||
tempHome: string;
|
||||
mcpServers: Record<string, McpHttpServerConfig>;
|
||||
isPublicRepo: boolean;
|
||||
tools: ToolPermissions;
|
||||
}
|
||||
|
||||
function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConfigParams): string {
|
||||
function writeCodexConfig({ tempHome, mcpServers, tools }: WriteCodexConfigParams): string {
|
||||
const codexDir = join(tempHome, ".codex");
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
const configPath = join(codexDir, "config.toml");
|
||||
@@ -45,32 +50,32 @@ function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConf
|
||||
const mcpServerSections: string[] = [];
|
||||
for (const [name, config] of Object.entries(mcpServers)) {
|
||||
if (config.type !== "http") continue;
|
||||
log.info(`» Adding MCP server '${name}' at ${config.url}`);
|
||||
log.info(`» adding MCP server '${name}' at ${config.url}`);
|
||||
mcpServerSections.push(`[mcp_servers.${name}]\nurl = "${config.url}"`);
|
||||
}
|
||||
|
||||
// SECURITY: for public repos, enforce env filtering via shell_environment_policy
|
||||
// this prevents vuln if user's ~/.codex/config.toml has ignore_default_excludes=true
|
||||
// for private repos, no filtering - agents use native shell with full env access
|
||||
const shellPolicy = isPublicRepo
|
||||
? `[shell_environment_policy]
|
||||
ignore_default_excludes = false`
|
||||
: "";
|
||||
// build features section for tool control
|
||||
// disable native shell if bash is "disabled" or "restricted"
|
||||
// when "restricted", agent uses MCP bash tool which filters secrets
|
||||
const features: string[] = [];
|
||||
if (tools.bash !== "enabled") {
|
||||
features.push("shell_command_tool = false");
|
||||
features.push("unified_exec = false");
|
||||
}
|
||||
const featuresSection = features.length > 0 ? `[features]\n${features.join("\n")}` : "";
|
||||
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`# written by pullfrog
|
||||
${shellPolicy}
|
||||
${featuresSection}
|
||||
|
||||
${mcpServerSections.join("\n\n")}
|
||||
`.trim() + "\n"
|
||||
);
|
||||
|
||||
if (isPublicRepo) {
|
||||
log.info(`» Codex config written to ${configPath} (env filtering: enabled)`);
|
||||
} else {
|
||||
log.info(`» Codex config written to ${configPath} (private repo: no env filtering)`);
|
||||
}
|
||||
log.info(
|
||||
`» Codex config written to ${configPath} (shell: ${tools.bash === "enabled" ? "enabled" : "disabled"})`
|
||||
);
|
||||
|
||||
return codexDir;
|
||||
}
|
||||
@@ -84,7 +89,7 @@ export const 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!;
|
||||
|
||||
// create config directory for codex before setting HOME
|
||||
@@ -94,7 +99,7 @@ export const codex = agent({
|
||||
const codexDir = writeCodexConfig({
|
||||
tempHome,
|
||||
mcpServers,
|
||||
isPublicRepo: repo.isPublic,
|
||||
tools,
|
||||
});
|
||||
|
||||
setupProcessAgentEnv({
|
||||
@@ -117,36 +122,29 @@ export const codex = agent({
|
||||
codexPathOverride: cliPath,
|
||||
};
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
}
|
||||
|
||||
const codex = new Codex(codexOptions);
|
||||
|
||||
// Build thread options with model and optional model_reasoning_effort
|
||||
const baseThreadOptions = payload.sandbox
|
||||
? {
|
||||
model,
|
||||
approvalPolicy: "never" as const,
|
||||
sandboxMode: "read-only" as const,
|
||||
networkAccessEnabled: false,
|
||||
}
|
||||
: {
|
||||
model,
|
||||
approvalPolicy: "never" as const,
|
||||
// use danger-full-access to allow git operations (workspace-write blocks .git directory writes)
|
||||
sandboxMode: "danger-full-access" as const,
|
||||
networkAccessEnabled: true,
|
||||
};
|
||||
// build thread options based on tool permissions
|
||||
const threadOptions: ThreadOptions = {
|
||||
model,
|
||||
approvalPolicy: "never" as const,
|
||||
// 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: ThreadOptions = modelReasoningEffort
|
||||
? { ...baseThreadOptions, modelReasoningEffort }
|
||||
: baseThreadOptions;
|
||||
log.info(
|
||||
`🔧 Codex options: sandboxMode=${threadOptions.sandboxMode}, networkAccessEnabled=${threadOptions.networkAccessEnabled}, webSearchEnabled=${threadOptions.webSearchEnabled}`
|
||||
);
|
||||
|
||||
const thread = codex.startThread(threadOptions);
|
||||
|
||||
try {
|
||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo }));
|
||||
const streamedTurn = await thread.runStreamed(addInstructions({ payload, repo, tools }));
|
||||
|
||||
let finalOutput = "";
|
||||
for await (const event of streamedTurn.events) {
|
||||
|
||||
+39
-42
@@ -10,6 +10,7 @@ import {
|
||||
type ConfigureMcpServersParams,
|
||||
createAgentEnv,
|
||||
installFromCurl,
|
||||
type ToolPermissions,
|
||||
} from "./shared.ts";
|
||||
|
||||
// effort configuration for Cursor
|
||||
@@ -100,9 +101,9 @@ export const 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 });
|
||||
|
||||
// determine model based on effort level
|
||||
// respect project's .cursor/cli.json if it specifies a model
|
||||
@@ -201,11 +202,10 @@ export const cursor = agent({
|
||||
};
|
||||
|
||||
try {
|
||||
const fullPrompt = addInstructions({ payload, repo });
|
||||
const fullPrompt = addInstructions({ payload, repo, tools });
|
||||
log.group("Full prompt", () => log.info(fullPrompt));
|
||||
|
||||
// configure sandbox mode if enabled
|
||||
// in sandbox mode: remove --force flag and rely on cli-config.json sandbox settings
|
||||
// build CLI args
|
||||
const baseArgs = ["--print", fullPrompt, "--output-format", "stream-json", "--approve-mcps"];
|
||||
|
||||
// add model flag if we have an override
|
||||
@@ -213,13 +213,8 @@ export const cursor = agent({
|
||||
baseArgs.push("--model", modelOverride);
|
||||
}
|
||||
|
||||
const cursorArgs = payload.sandbox
|
||||
? baseArgs // --force removed in sandbox mode to enforce safety checks
|
||||
: [...baseArgs, "--force"];
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
}
|
||||
// always use --force since permissions are controlled via cli-config.json
|
||||
const cursorArgs = [...baseArgs, "--force"];
|
||||
|
||||
log.info("Running Cursor CLI...");
|
||||
|
||||
@@ -347,48 +342,50 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
||||
log.info(`» MCP config written to ${mcpConfigPath}`);
|
||||
}
|
||||
|
||||
interface ConfigureCursorToolsParams {
|
||||
tools: ToolPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
||||
*
|
||||
* SECURITY: For PUBLIC repos, Cursor spawns subprocesses with full process.env, leaking API keys.
|
||||
* We deny native Shell via Shell(*) rule, forcing use of MCP bash tool which
|
||||
* filters secrets. Note: Shell(**) does NOT work, must use Shell(*).
|
||||
* For private repos, native Shell is allowed.
|
||||
* Configure Cursor CLI tool permissions via cli-config.json.
|
||||
*
|
||||
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
||||
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
||||
* sets XDG_CONFIG_HOME=$HOME/.config.
|
||||
*/
|
||||
function configureCursorSandbox({
|
||||
sandbox,
|
||||
isPublicRepo,
|
||||
}: {
|
||||
sandbox: boolean;
|
||||
isPublicRepo: boolean;
|
||||
}): void {
|
||||
function configureCursorTools({ tools }: ConfigureCursorToolsParams): void {
|
||||
const realHome = homedir();
|
||||
const cursorConfigDir = join(realHome, ".config", "cursor");
|
||||
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
||||
mkdirSync(cursorConfigDir, { recursive: true });
|
||||
|
||||
// deny native shell for public repos to prevent secret leakage
|
||||
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||
// build deny list based on tool permissions
|
||||
const deny: string[] = [];
|
||||
if (tools.search === "disabled") deny.push("WebSearch");
|
||||
if (tools.write === "disabled") deny.push("Write(**)");
|
||||
// both "disabled" and "restricted" block native shell
|
||||
if (tools.bash !== "enabled") deny.push("Shell(*)");
|
||||
|
||||
const config = sandbox
|
||||
? {
|
||||
permissions: {
|
||||
allow: ["Read(**)"],
|
||||
deny: ["Write(**)", ...denyShell],
|
||||
},
|
||||
}
|
||||
: {
|
||||
permissions: {
|
||||
allow: ["Read(**)", "Write(**)"],
|
||||
deny: denyShell,
|
||||
},
|
||||
};
|
||||
// web: "disabled" requires sandbox with network blocking
|
||||
// sandbox.networkAccess: "allowlist" blocks network in shell subprocesses via seatbelt
|
||||
const needsSandbox = tools.web === "disabled";
|
||||
|
||||
const config: Record<string, unknown> = {
|
||||
permissions: {
|
||||
allow: tools.write === "disabled" ? ["Read(**)"] : ["Read(**)", "Write(**)"],
|
||||
deny,
|
||||
},
|
||||
};
|
||||
|
||||
if (needsSandbox) {
|
||||
config.sandbox = {
|
||||
mode: "enabled",
|
||||
networkAccess: "allowlist",
|
||||
};
|
||||
}
|
||||
|
||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
log.info(`» CLI config written to ${cliConfigPath}`);
|
||||
log.info(
|
||||
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
||||
`🔧 Cursor permissions: allow=${(config.permissions as { allow: string[] }).allow.join(",")}, deny=${deny.join(",") || "(none)"}, sandbox=${needsSandbox ? "enabled" : "disabled"}`
|
||||
);
|
||||
}
|
||||
|
||||
+27
-44
@@ -10,6 +10,7 @@ import {
|
||||
type ConfigureMcpServersParams,
|
||||
createAgentEnv,
|
||||
installFromGithub,
|
||||
type ToolPermissions,
|
||||
} from "./shared.ts";
|
||||
|
||||
// effort configuration: model + thinking level
|
||||
@@ -171,48 +172,23 @@ export const gemini = agent({
|
||||
...(githubInstallationToken && { githubInstallationToken }),
|
||||
});
|
||||
},
|
||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort }) => {
|
||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo, effort, tools }) => {
|
||||
// get model and thinking level based on effort
|
||||
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));
|
||||
|
||||
// build CLI args based on sandbox mode
|
||||
// for public repos, native shell is disabled via excludeTools in settings.json
|
||||
let args: string[];
|
||||
if (payload.sandbox) {
|
||||
// sandbox mode: read-only tools only
|
||||
args = [
|
||||
"--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 {
|
||||
// normal mode: --yolo for auto-approval
|
||||
// for public repos, shell is excluded via settings.json excludeTools
|
||||
args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||
if (repo.isPublic) {
|
||||
log.info("🔒 public repo: native shell disabled via excludeTools, using MCP bash");
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
}
|
||||
// build CLI args - --yolo for auto-approval
|
||||
// tool restrictions handled via settings.json tools.exclude
|
||||
const args = ["--model", model, "--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
||||
|
||||
let finalOutput = "";
|
||||
let stdoutBuffer = "";
|
||||
@@ -294,23 +270,23 @@ export const gemini = agent({
|
||||
},
|
||||
});
|
||||
|
||||
type ConfigureGeminiParams = {
|
||||
interface ConfigureGeminiParams {
|
||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||
isPublicRepo: boolean;
|
||||
tools: ToolPermissions;
|
||||
thinkingLevel: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Gemini CLI settings by writing to settings.json.
|
||||
* - MCP servers: uses `httpUrl` for HTTP/streamable transport
|
||||
* - thinkingLevel: configured via modelConfig.generateContentConfig.thinkingConfig
|
||||
* - For public repos, excludeTools disables native shell
|
||||
* - tools.exclude: disables native tools based on ToolPermissions (v0.3.0+ format)
|
||||
*
|
||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||
*/
|
||||
function configureGeminiSettings({
|
||||
mcpServers,
|
||||
isPublicRepo,
|
||||
tools,
|
||||
thinkingLevel,
|
||||
}: ConfigureGeminiParams): void {
|
||||
const realHome = homedir();
|
||||
@@ -328,7 +304,7 @@ function configureGeminiSettings({
|
||||
}
|
||||
|
||||
// convert to Gemini's expected format (httpUrl for HTTP transport, no type field)
|
||||
type GeminiMcpServerConfig = {
|
||||
interface GeminiMcpServerConfig {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
@@ -341,7 +317,7 @@ function configureGeminiSettings({
|
||||
description?: string;
|
||||
includeTools?: string[];
|
||||
excludeTools?: string[];
|
||||
};
|
||||
}
|
||||
const geminiMcpServers: Record<string, GeminiMcpServerConfig> = {};
|
||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
||||
if (serverConfig.type !== "http") {
|
||||
@@ -353,9 +329,16 @@ function configureGeminiSettings({
|
||||
httpUrl: serverConfig.url,
|
||||
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}...`);
|
||||
}
|
||||
|
||||
// build tools.exclude based on permissions (v0.3.0+ nested format)
|
||||
const exclude: string[] = [];
|
||||
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");
|
||||
|
||||
// merge with existing settings, overwriting mcpServers and modelConfig
|
||||
const newSettings: Record<string, unknown> = {
|
||||
...existingSettings,
|
||||
@@ -369,13 +352,13 @@ function configureGeminiSettings({
|
||||
},
|
||||
},
|
||||
},
|
||||
// v0.3.0+ nested format
|
||||
...(exclude.length > 0 && { tools: { exclude } }),
|
||||
};
|
||||
|
||||
// for public repos, exclude native shell tool to prevent secret leakage via env
|
||||
if (isPublicRepo) {
|
||||
newSettings.excludeTools = ["run_shell_command"];
|
||||
}
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||
log.info(`» Gemini settings written to ${settingsPath}`);
|
||||
if (exclude.length > 0) {
|
||||
log.info(`🔒 excluded tools: ${exclude.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-9
@@ -3,6 +3,7 @@ import { encode as toonEncode } from "@toon-format/toon";
|
||||
import type { Payload } from "../external.ts";
|
||||
import { ghPullfrogMcpName } from "../external.ts";
|
||||
import { getModes } from "../modes.ts";
|
||||
import type { ToolPermissions } from "./shared.ts";
|
||||
|
||||
interface RepoInfo {
|
||||
owner: string;
|
||||
@@ -54,12 +55,28 @@ function buildRuntimeContext(repo: RepoInfo): string {
|
||||
interface AddInstructionsParams {
|
||||
payload: Payload;
|
||||
repo: RepoInfo;
|
||||
tools: ToolPermissions;
|
||||
}
|
||||
|
||||
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
||||
// for public repos, always use MCP bash for security (filters secrets)
|
||||
// for private repos, agents can use their native bash
|
||||
const useNativeBash = !repo.isPublic;
|
||||
/**
|
||||
* Generate shell instructions based on bash permission level.
|
||||
*/
|
||||
function getShellInstructions(bash: ToolPermissions["bash"]): string {
|
||||
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: never = bash;
|
||||
return _exhaustive satisfies never;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const addInstructions = ({ payload, repo, tools }: AddInstructionsParams) => {
|
||||
let encodedEvent = "";
|
||||
|
||||
const eventKeys = Object.keys(payload.event);
|
||||
@@ -142,11 +159,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.
|
||||
|
||||
|
||||
+19
-34
@@ -1,5 +1,4 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
import { join } from "node:path";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { spawn } from "../utils/subprocess.ts";
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
createAgentEnv,
|
||||
installFromNpmTarball,
|
||||
setupProcessAgentEnv,
|
||||
type ToolPermissions,
|
||||
} from "./shared.ts";
|
||||
|
||||
export const opencode = agent({
|
||||
@@ -30,28 +30,21 @@ export const opencode = agent({
|
||||
cliPath,
|
||||
repo,
|
||||
effort: _effort,
|
||||
tools,
|
||||
}) => {
|
||||
// 1. configure home/config directory
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
const configDir = join(tempHome, ".config", "opencode");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
|
||||
configureOpenCode({
|
||||
mcpServers,
|
||||
sandbox: payload.sandbox ?? false,
|
||||
isPublicRepo: repo.isPublic,
|
||||
});
|
||||
configureOpenCode({ mcpServers, tools });
|
||||
|
||||
const prompt = addInstructions({ payload, repo });
|
||||
const prompt = addInstructions({ payload, repo, tools });
|
||||
log.group("Full prompt", () => log.info(prompt));
|
||||
|
||||
// message positional must come right after "run", before flags
|
||||
const args = ["run", prompt, "--format", "json"];
|
||||
|
||||
if (payload.sandbox) {
|
||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||
}
|
||||
|
||||
// 6. set up environment
|
||||
setupProcessAgentEnv({ HOME: tempHome });
|
||||
|
||||
@@ -200,15 +193,14 @@ export const opencode = agent({
|
||||
|
||||
interface ConfigureOpenCodeParams {
|
||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||
sandbox: boolean;
|
||||
isPublicRepo: boolean;
|
||||
tools: ToolPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure OpenCode via opencode.json config file.
|
||||
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
||||
*/
|
||||
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
||||
function configureOpenCode({ mcpServers, tools }: ConfigureOpenCodeParams): void {
|
||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||
const configDir = join(tempHome, ".config", "opencode");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
@@ -232,25 +224,15 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenC
|
||||
};
|
||||
}
|
||||
|
||||
// SECURITY: For PUBLIC repos, OpenCode spawns subprocesses with full process.env, leaking API keys.
|
||||
// disable native bash; agents use MCP bash tool which filters secrets.
|
||||
// for private repos, native bash is allowed.
|
||||
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",
|
||||
doom_loop: "allow",
|
||||
external_directory: "allow",
|
||||
};
|
||||
// build permission object based on tool permissions
|
||||
// note: OpenCode has no built-in web search tool
|
||||
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",
|
||||
};
|
||||
|
||||
// build complete config in one object
|
||||
const config = {
|
||||
@@ -268,7 +250,10 @@ function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenC
|
||||
throw error;
|
||||
}
|
||||
|
||||
log.info(`» OpenCode config written to ${configPath} (sandbox: ${sandbox})`);
|
||||
log.info(`» OpenCode config written to ${configPath}`);
|
||||
log.info(
|
||||
`🔧 OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
|
||||
);
|
||||
log.debug(`OpenCode config contents:\n${configJson}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,22 @@ export interface RepoInfo {
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool permission levels
|
||||
*/
|
||||
export type ToolPermission = "disabled" | "enabled";
|
||||
export type BashPermission = "disabled" | "restricted" | "enabled";
|
||||
|
||||
/**
|
||||
* Granular tool permissions for agents
|
||||
*/
|
||||
export interface ToolPermissions {
|
||||
web: ToolPermission;
|
||||
search: ToolPermission;
|
||||
write: ToolPermission;
|
||||
bash: BashPermission;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for agent creation
|
||||
*/
|
||||
@@ -47,6 +63,7 @@ export interface AgentConfig {
|
||||
cliPath: string;
|
||||
repo: RepoInfo;
|
||||
effort: Effort;
|
||||
tools: ToolPermissions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user