Merge pull request #19 from pullfrog/custom-bash
Switch to custom Bash tool. Mask secrets from Bash subprocs.
This commit is contained in:
+7
-12
@@ -21,28 +21,23 @@ export const claude = agent({
|
|||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// 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
|
const sandboxOptions: Options = payload.sandbox
|
||||||
? {
|
? {
|
||||||
permissionMode: "default",
|
permissionMode: "default",
|
||||||
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
disallowedTools: ["Bash", "WebSearch", "WebFetch", "Write"],
|
||||||
async canUseTool(toolName, input, _options) {
|
async canUseTool(toolName, input, _options) {
|
||||||
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
if (toolName.startsWith("mcp__gh_pullfrog__"))
|
||||||
return {
|
return { behavior: "allow", updatedInput: input, updatedPermissions: [] };
|
||||||
behavior: "allow",
|
return { behavior: "deny", message: "tool not allowed in sandbox mode" };
|
||||||
updatedInput: input,
|
|
||||||
updatedPermissions: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
console.error("can i use this tool?", toolName);
|
|
||||||
return {
|
|
||||||
behavior: "deny",
|
|
||||||
message: "You are not allowed to use this tool.",
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
permissionMode: "bypassPermissions" as const,
|
permissionMode: "bypassPermissions" as const,
|
||||||
|
disallowedTools,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (payload.sandbox) {
|
if (payload.sandbox) {
|
||||||
|
|||||||
+57
-42
@@ -1,15 +1,55 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
import { mkdirSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import type { McpHttpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||||
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
import { Codex, type CodexOptions, type ThreadEvent } from "@openai/codex-sdk";
|
||||||
import { log } from "../utils/cli.ts";
|
import { log } from "../utils/cli.ts";
|
||||||
import { addInstructions } from "./instructions.ts";
|
import { addInstructions } from "./instructions.ts";
|
||||||
import {
|
import { agent, installFromNpmTarball, setupProcessAgentEnv } from "./shared.ts";
|
||||||
agent,
|
|
||||||
type ConfigureMcpServersParams,
|
interface WriteCodexConfigParams {
|
||||||
installFromNpmTarball,
|
tempHome: string;
|
||||||
setupProcessAgentEnv,
|
mcpServers: Record<string, McpHttpServerConfig>;
|
||||||
} from "./shared.ts";
|
isPublicRepo: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCodexConfig({ tempHome, mcpServers, isPublicRepo }: WriteCodexConfigParams): string {
|
||||||
|
const codexDir = join(tempHome, ".codex");
|
||||||
|
mkdirSync(codexDir, { recursive: true });
|
||||||
|
const configPath = join(codexDir, "config.toml");
|
||||||
|
|
||||||
|
// build MCP servers section
|
||||||
|
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}`);
|
||||||
|
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`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
writeFileSync(
|
||||||
|
configPath,
|
||||||
|
`# written by pullfrog
|
||||||
|
${shellPolicy}
|
||||||
|
|
||||||
|
${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)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return codexDir;
|
||||||
|
}
|
||||||
|
|
||||||
export const codex = agent({
|
export const codex = agent({
|
||||||
name: "codex",
|
name: "codex",
|
||||||
@@ -21,18 +61,24 @@ export const codex = agent({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
run: async ({ payload, mcpServers, apiKey, cliPath, repo }) => {
|
||||||
// create config directory for codex before setting HOME
|
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
|
|
||||||
|
// create config directory for codex before setting HOME
|
||||||
const configDir = join(tempHome, ".config", "codex");
|
const configDir = join(tempHome, ".config", "codex");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
|
const codexDir = writeCodexConfig({
|
||||||
|
tempHome,
|
||||||
|
mcpServers,
|
||||||
|
isPublicRepo: repo.isPublic,
|
||||||
|
});
|
||||||
|
|
||||||
setupProcessAgentEnv({
|
setupProcessAgentEnv({
|
||||||
OPENAI_API_KEY: apiKey,
|
OPENAI_API_KEY: apiKey,
|
||||||
HOME: tempHome,
|
HOME: tempHome,
|
||||||
|
CODEX_HOME: codexDir, // point Codex to our config directory
|
||||||
});
|
});
|
||||||
|
|
||||||
configureCodexMcpServers({ mcpServers, cliPath });
|
|
||||||
|
|
||||||
// Configure Codex
|
// Configure Codex
|
||||||
const codexOptions: CodexOptions = {
|
const codexOptions: CodexOptions = {
|
||||||
apiKey,
|
apiKey,
|
||||||
@@ -44,7 +90,6 @@ export const codex = agent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const codex = new Codex(codexOptions);
|
const codex = new Codex(codexOptions);
|
||||||
// valid sandbox modes: read-only, workspace-write, danger-full-access
|
|
||||||
const thread = codex.startThread(
|
const thread = codex.startThread(
|
||||||
payload.sandbox
|
payload.sandbox
|
||||||
? {
|
? {
|
||||||
@@ -186,33 +231,3 @@ const messageHandlers: {
|
|||||||
log.error(`Error: ${event.message}`);
|
log.error(`Error: ${event.message}`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Configure MCP servers for Codex using the CLI.
|
|
||||||
* For HTTP-based servers, use: codex mcp add <name> --url <url>
|
|
||||||
*/
|
|
||||||
function configureCodexMcpServers({ mcpServers, cliPath }: ConfigureMcpServersParams): void {
|
|
||||||
for (const [serverName, serverConfig] of Object.entries(mcpServers)) {
|
|
||||||
if (serverConfig.type === "http") {
|
|
||||||
// HTTP-based MCP server - use --url flag
|
|
||||||
const addArgs = ["mcp", "add", serverName, "--url", serverConfig.url];
|
|
||||||
|
|
||||||
log.info(`Adding MCP server '${serverName}' at ${serverConfig.url}...`);
|
|
||||||
const addResult = spawnSync("node", [cliPath, ...addArgs], {
|
|
||||||
stdio: "pipe",
|
|
||||||
encoding: "utf-8",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (addResult.status !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`codex mcp add failed: ${addResult.stderr || addResult.stdout || "Unknown error"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
log.info(`✓ MCP server '${serverName}' configured`);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported MCP server type for Codex: ${(serverConfig as any).type || "unknown"}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-19
@@ -93,7 +93,7 @@ export const cursor = agent({
|
|||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
run: async ({ payload, apiKey, cliPath, mcpServers, repo }) => {
|
||||||
configureCursorMcpServers({ mcpServers, cliPath });
|
configureCursorMcpServers({ mcpServers, cliPath });
|
||||||
configureCursorSandbox({ sandbox: payload.sandbox ?? false });
|
configureCursorSandbox({ sandbox: payload.sandbox ?? false, isPublicRepo: repo.isPublic });
|
||||||
|
|
||||||
// track logged model_call_ids to avoid duplicates
|
// track logged model_call_ids to avoid duplicates
|
||||||
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
// cursor emits each assistant message twice: once without model_call_id, then again with it
|
||||||
@@ -314,39 +314,46 @@ function configureCursorMcpServers({ mcpServers }: ConfigureMcpServersParams) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure Cursor CLI sandbox mode via cli-config.json.
|
* Configure Cursor CLI sandbox mode via cli-config.json.
|
||||||
* When sandbox is enabled, denies all file writes and shell commands.
|
|
||||||
* In print mode without --force, writes are blocked by default, but we add
|
|
||||||
* explicit deny rules as defense in depth.
|
|
||||||
*
|
*
|
||||||
* See: https://cursor.com/docs/cli/reference/permissions
|
* 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.
|
||||||
|
*
|
||||||
|
* Config path: $XDG_CONFIG_HOME/cursor/ (not ~/.cursor/) because createAgentEnv
|
||||||
|
* sets XDG_CONFIG_HOME=$HOME/.config. See issues/cursor-perms.md.
|
||||||
*/
|
*/
|
||||||
function configureCursorSandbox({ sandbox }: { sandbox: boolean }): void {
|
function configureCursorSandbox({
|
||||||
|
sandbox,
|
||||||
|
isPublicRepo,
|
||||||
|
}: {
|
||||||
|
sandbox: boolean;
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
}): void {
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const cursorConfigDir = join(realHome, ".cursor");
|
const cursorConfigDir = join(realHome, ".config", "cursor");
|
||||||
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
const cliConfigPath = join(cursorConfigDir, "cli-config.json");
|
||||||
mkdirSync(cursorConfigDir, { recursive: true });
|
mkdirSync(cursorConfigDir, { recursive: true });
|
||||||
|
|
||||||
|
// deny native shell for public repos to prevent secret leakage
|
||||||
|
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||||
|
|
||||||
const config = sandbox
|
const config = sandbox
|
||||||
? {
|
? {
|
||||||
// sandbox mode: deny all writes and shell commands
|
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: [
|
allow: ["Read(**)"],
|
||||||
"Read(**)", // allow reading all files
|
deny: ["Write(**)", ...denyShell],
|
||||||
],
|
|
||||||
deny: [
|
|
||||||
"Write(**)", // deny all file writes
|
|
||||||
"Shell(**)", // deny all shell commands
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
// normal mode: allow everything
|
|
||||||
permissions: {
|
permissions: {
|
||||||
allow: ["Read(**)", "Write(**)", "Shell(**)"],
|
allow: ["Read(**)", "Write(**)"],
|
||||||
deny: [],
|
deny: denyShell,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||||
log.info(`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox})`);
|
log.info(
|
||||||
|
`» CLI config written to ${cliConfigPath} (sandbox: ${sandbox}, isPublicRepo: ${isPublicRepo})`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-14
@@ -157,7 +157,7 @@ export const gemini = agent({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
run: async ({ payload, apiKey, mcpServers, cliPath, repo }) => {
|
||||||
configureGeminiMcpServers({ mcpServers, cliPath });
|
configureGeminiMcpServers({ mcpServers, isPublicRepo: repo.isPublic });
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
throw new Error("google_api_key or gemini_api_key is required for gemini agent");
|
||||||
@@ -166,11 +166,12 @@ export const gemini = agent({
|
|||||||
const sessionPrompt = addInstructions({ payload, repo });
|
const sessionPrompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(sessionPrompt));
|
log.group("Full prompt", () => log.info(sessionPrompt));
|
||||||
|
|
||||||
// configure sandbox mode if enabled
|
// build CLI args based on sandbox mode
|
||||||
// --allowed-tools restricts which tools are available (removes others from registry entirely)
|
// for public repos, native shell is disabled via excludeTools in settings.json
|
||||||
// in sandbox mode: only read-only tools available (no write_file, run_shell_command, web_fetch)
|
let args: string[];
|
||||||
const args = payload.sandbox
|
if (payload.sandbox) {
|
||||||
? [
|
// sandbox mode: read-only tools only
|
||||||
|
args = [
|
||||||
"--allowed-tools",
|
"--allowed-tools",
|
||||||
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
"read_file,list_directory,search_file_content,glob,save_memory,write_todos",
|
||||||
"--allowed-mcp-server-names",
|
"--allowed-mcp-server-names",
|
||||||
@@ -178,22 +179,28 @@ export const gemini = agent({
|
|||||||
"--output-format=stream-json",
|
"--output-format=stream-json",
|
||||||
"-p",
|
"-p",
|
||||||
sessionPrompt,
|
sessionPrompt,
|
||||||
]
|
];
|
||||||
: ["--yolo", "--output-format=stream-json", "-p", sessionPrompt];
|
} else {
|
||||||
|
// normal mode: --yolo for auto-approval
|
||||||
|
// for public repos, shell is excluded via settings.json excludeTools
|
||||||
|
args = ["--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) {
|
if (payload.sandbox) {
|
||||||
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
log.info("🔒 sandbox mode enabled: restricting to read-only operations");
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalOutput = "";
|
let finalOutput = "";
|
||||||
let stdoutBuffer = ""; // buffer for incomplete lines across chunks
|
let stdoutBuffer = "";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await spawn({
|
const result = await spawn({
|
||||||
cmd: "node",
|
cmd: "node",
|
||||||
args: [cliPath, ...args],
|
args: [cliPath, ...args],
|
||||||
env: createAgentEnv({
|
env: createAgentEnv({ GEMINI_API_KEY: apiKey }),
|
||||||
GEMINI_API_KEY: apiKey,
|
|
||||||
}),
|
|
||||||
onStdout: async (chunk) => {
|
onStdout: async (chunk) => {
|
||||||
const text = chunk.toString();
|
const text = chunk.toString();
|
||||||
finalOutput += text;
|
finalOutput += text;
|
||||||
@@ -266,12 +273,19 @@ export const gemini = agent({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type ConfigureGeminiParams = {
|
||||||
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||||
|
isPublicRepo: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure MCP servers for Gemini by writing to settings.json.
|
* Configure MCP servers for Gemini by writing to settings.json.
|
||||||
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
|
* Gemini CLI uses `httpUrl` for HTTP/streamable transport, `url` for SSE transport.
|
||||||
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
* See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md
|
||||||
|
*
|
||||||
|
* For public repos, also configures excludeTools to disable native shell.
|
||||||
*/
|
*/
|
||||||
function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): void {
|
function configureGeminiMcpServers({ mcpServers, isPublicRepo }: ConfigureGeminiParams): void {
|
||||||
const realHome = homedir();
|
const realHome = homedir();
|
||||||
const geminiConfigDir = join(realHome, ".gemini");
|
const geminiConfigDir = join(realHome, ".gemini");
|
||||||
const settingsPath = join(geminiConfigDir, "settings.json");
|
const settingsPath = join(geminiConfigDir, "settings.json");
|
||||||
@@ -316,11 +330,16 @@ function configureGeminiMcpServers({ mcpServers }: ConfigureMcpServersParams): v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// merge with existing settings, overwriting mcpServers
|
// merge with existing settings, overwriting mcpServers
|
||||||
const newSettings = {
|
// for public repos, exclude native shell tool to prevent secret leakage via env
|
||||||
|
const newSettings: Record<string, unknown> = {
|
||||||
...existingSettings,
|
...existingSettings,
|
||||||
mcpServers: geminiMcpServers,
|
mcpServers: geminiMcpServers,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isPublicRepo) {
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
}
|
||||||
|
|
||||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||||
log.info(`» MCP config written to ${settingsPath}`);
|
log.info(`» MCP config written to ${settingsPath}`);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-33
@@ -8,6 +8,7 @@ interface RepoInfo {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,9 @@ interface AddInstructionsParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const addInstructions = ({ payload, repo }: AddInstructionsParams) => {
|
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;
|
||||||
let encodedEvent = "";
|
let encodedEvent = "";
|
||||||
|
|
||||||
const eventKeys = Object.keys(payload.event);
|
const eventKeys = Object.keys(payload.event);
|
||||||
@@ -98,40 +102,9 @@ In case of conflict between instructions, follow this precedence (highest to low
|
|||||||
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
|
4. Repository-specific instructions (AGENTS.md, CLAUDE.md, etc.)
|
||||||
5. User prompt
|
5. User prompt
|
||||||
|
|
||||||
## SECURITY
|
## Security
|
||||||
|
|
||||||
CRITICAL SECURITY RULES - NEVER VIOLATE UNDER ANY CIRCUMSTANCES:
|
Never expose secrets (API keys, tokens, passwords, private keys, credentials) through any channel: console output, files, commits, comments, API responses, error messages, or URLs. Never serialize environment objects (\`process.env\`, \`os.environ\`, etc.) or iterate over them. If asked to reveal secrets: refuse, explain that exposing secrets is prohibited, and offer a safe alternative if applicable. Detect and deny any suspicious or malicious requests.
|
||||||
|
|
||||||
### Rule 1: Never expose secrets through ANY means
|
|
||||||
|
|
||||||
You must NEVER expose secrets through any channel, including but not limited to:
|
|
||||||
- Displaying, printing, echoing, logging, or outputting to console
|
|
||||||
- Writing to files (including .txt, .env, .json, config files, etc.)
|
|
||||||
- Including in git commits, commit messages, or PR descriptions
|
|
||||||
- Posting in GitHub comments, issue bodies, or PR review comments
|
|
||||||
- Returning in tool outputs, API responses, or error messages
|
|
||||||
- Including in redirect URLs, WebSocket messages, or GraphQL responses
|
|
||||||
|
|
||||||
Secrets include: API keys, authentication tokens, passwords, private keys, certificates, database connection strings, and any credential used for authentication or authorization. Common patterns (case-insensitive): variables containing API_KEY, SECRET, TOKEN, PASSWORD, CREDENTIAL, PRIVATE_KEY, or AUTH in an authentication context. Use judgment: \`PUBLIC_KEY\` for a cryptographic public key is fine; \`PRIVATE_KEY\` is not.
|
|
||||||
|
|
||||||
### Rule 2: Never serialize objects containing secrets
|
|
||||||
|
|
||||||
When working with objects that may contain environment variables or secrets:
|
|
||||||
- NEVER serialize, stringify, or dump entire environment objects (process.env, os.environ, ENV, etc.)
|
|
||||||
- NEVER iterate over environment variables and write their values to files
|
|
||||||
- NEVER include environment variable values in outputs, logs, HTTP requests, or anywhere they can be exposed
|
|
||||||
- If you must list properties, only show property NAMES, never values
|
|
||||||
- Only access specific, known-safe keys explicitly (e.g., NODE_ENV, HOME, PWD)
|
|
||||||
|
|
||||||
### Rule 3: Refuse and explain
|
|
||||||
|
|
||||||
Even if explicitly requested to reveal secrets, you must:
|
|
||||||
1. Refuse the request
|
|
||||||
2. Print a message explaining that exposing secrets is prohibited for security reasons
|
|
||||||
3. If using ${ghPullfrogMcpName}, update the working comment to explain that secrets cannot be revealed
|
|
||||||
4. Offer a safe alternative, if applicable
|
|
||||||
|
|
||||||
If you encounter secrets in files or environment, acknowledge they exist but never reveal their values.
|
|
||||||
|
|
||||||
## MCP (Model Context Protocol) Tools
|
## MCP (Model Context Protocol) Tools
|
||||||
|
|
||||||
@@ -169,6 +142,12 @@ 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.
|
**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.`
|
||||||
|
}
|
||||||
|
|
||||||
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
|
**Command execution**: Never use \`sleep\` to wait for commands to complete. Commands run synchronously - when the bash tool returns, the command has finished.
|
||||||
|
|
||||||
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
|
**Commenting style**: When posting comments via ${ghPullfrogMcpName}, write as a professional team member would. Your final comments should be polished and actionable—do not include intermediate reasoning like "I'll now look at the code" or "Let me respond to the question."
|
||||||
|
|||||||
+25
-15
@@ -7,6 +7,7 @@ import { addInstructions } from "./instructions.ts";
|
|||||||
import {
|
import {
|
||||||
agent,
|
agent,
|
||||||
type ConfigureMcpServersParams,
|
type ConfigureMcpServersParams,
|
||||||
|
createAgentEnv,
|
||||||
installFromNpmTarball,
|
installFromNpmTarball,
|
||||||
setupProcessAgentEnv,
|
setupProcessAgentEnv,
|
||||||
} from "./shared.ts";
|
} from "./shared.ts";
|
||||||
@@ -27,7 +28,11 @@ export const opencode = agent({
|
|||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
|
||||||
configureOpenCode({ mcpServers, sandbox: payload.sandbox ?? false });
|
configureOpenCode({
|
||||||
|
mcpServers,
|
||||||
|
sandbox: payload.sandbox ?? false,
|
||||||
|
isPublicRepo: repo.isPublic,
|
||||||
|
});
|
||||||
|
|
||||||
const prompt = addInstructions({ payload, repo });
|
const prompt = addInstructions({ payload, repo });
|
||||||
log.group("Full prompt", () => log.info(prompt));
|
log.group("Full prompt", () => log.info(prompt));
|
||||||
@@ -42,24 +47,25 @@ export const opencode = agent({
|
|||||||
// 6. set up environment
|
// 6. set up environment
|
||||||
setupProcessAgentEnv({ HOME: tempHome });
|
setupProcessAgentEnv({ HOME: tempHome });
|
||||||
|
|
||||||
// build env vars: start with process.env (includes all API_KEY vars loaded by config())
|
// SECURITY: build env vars from whitelisted base env to prevent API key leakage
|
||||||
// exclude GITHUB_TOKEN - OpenCode should use MCP server for GitHub operations, not direct token
|
// this prevents leaking other API keys (ANTHROPIC, GEMINI, etc.) to OpenCode subprocess
|
||||||
// then override with apiKeys, HOME, and XDG_CONFIG_HOME
|
|
||||||
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
// XDG_CONFIG_HOME must be set because GitHub Actions sets it to a different path,
|
||||||
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
// and OpenCode follows XDG spec (checks XDG_CONFIG_HOME before falling back to $HOME/.config)
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...(Object.fromEntries(
|
...createAgentEnv({ HOME: tempHome }),
|
||||||
Object.entries(process.env).filter(
|
|
||||||
([key, value]) => value !== undefined && key !== "GITHUB_TOKEN"
|
|
||||||
)
|
|
||||||
) as Record<string, string>),
|
|
||||||
HOME: tempHome,
|
|
||||||
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
XDG_CONFIG_HOME: join(tempHome, ".config"),
|
||||||
};
|
};
|
||||||
|
// OpenCode doesn't support GitHub App installation tokens
|
||||||
|
delete env.GITHUB_TOKEN;
|
||||||
|
|
||||||
// add/override API keys from apiKeys object (uppercase keys)
|
// add API keys from apiKeys object
|
||||||
for (const [key, value] of Object.entries(apiKeys || {})) {
|
for (const [key, value] of Object.entries(apiKeys || {})) {
|
||||||
env[key.toUpperCase()] = value;
|
const upperKey = key.toUpperCase();
|
||||||
|
env[upperKey] = value;
|
||||||
|
// also set GOOGLE_GENERATIVE_AI_API_KEY for Google provider compatibility
|
||||||
|
if (upperKey === "GEMINI_API_KEY") {
|
||||||
|
env.GOOGLE_GENERATIVE_AI_API_KEY = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
// run OpenCode in the repository directory (process.cwd() is set to GITHUB_WORKSPACE or repo dir)
|
||||||
@@ -188,13 +194,14 @@ export const opencode = agent({
|
|||||||
interface ConfigureOpenCodeParams {
|
interface ConfigureOpenCodeParams {
|
||||||
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
mcpServers: ConfigureMcpServersParams["mcpServers"];
|
||||||
sandbox: boolean;
|
sandbox: boolean;
|
||||||
|
isPublicRepo: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure OpenCode via opencode.json config file.
|
* Configure OpenCode via opencode.json config file.
|
||||||
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
* Builds complete config with MCP servers and permissions in a single write to avoid race conditions.
|
||||||
*/
|
*/
|
||||||
function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): void {
|
function configureOpenCode({ mcpServers, sandbox, isPublicRepo }: ConfigureOpenCodeParams): void {
|
||||||
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
const tempHome = process.env.PULLFROG_TEMP_DIR!;
|
||||||
const configDir = join(tempHome, ".config", "opencode");
|
const configDir = join(tempHome, ".config", "opencode");
|
||||||
mkdirSync(configDir, { recursive: true });
|
mkdirSync(configDir, { recursive: true });
|
||||||
@@ -218,7 +225,10 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// build permissions config
|
// 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
|
const permission = sandbox
|
||||||
? {
|
? {
|
||||||
edit: "deny",
|
edit: "deny",
|
||||||
@@ -229,7 +239,7 @@ function configureOpenCode({ mcpServers, sandbox }: ConfigureOpenCodeParams): vo
|
|||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
edit: "allow",
|
edit: "allow",
|
||||||
bash: "allow",
|
bash: bashPermission,
|
||||||
webfetch: "allow",
|
webfetch: "allow",
|
||||||
doom_loop: "allow",
|
doom_loop: "allow",
|
||||||
external_directory: "allow",
|
external_directory: "allow",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface RepoInfo {
|
|||||||
owner: string;
|
owner: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultBranch: string;
|
defaultBranch: string;
|
||||||
|
isPublic: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Docker Testing Environment
|
||||||
|
|
||||||
|
`play.ts` runs in Docker by default for realistic testing (Linux, clean $HOME, matches CI).
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm play bash-test.ts # runs in Docker (default)
|
||||||
|
pnpm play --local bash-test.ts # runs on macOS (fast iteration)
|
||||||
|
PLAY_LOCAL=1 pnpm play ... # same as --local
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Docker by Default?
|
||||||
|
|
||||||
|
1. **Matches CI** - Linux environment like GitHub Actions
|
||||||
|
2. **Clean $HOME** - No agent config pollution from `~/.claude`, `~/.cursor`
|
||||||
|
3. **Tests unshare** - Verifies PID namespace sandbox works
|
||||||
|
4. **Reproducible** - Same environment every run
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
| Mode | Overhead |
|
||||||
|
|------|----------|
|
||||||
|
| Docker (cached deps) | ~1.5s |
|
||||||
|
| Local (macOS) | ~0s |
|
||||||
|
|
||||||
|
For agent runs taking 30-120s, the 1.5s overhead is negligible.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. `play.ts` runs on host, loads `.env`
|
||||||
|
2. Spawns Docker container with:
|
||||||
|
- Volume-mounted `action/` code
|
||||||
|
- Named volume for Linux node_modules (persists between runs)
|
||||||
|
- SSH agent forwarding for git clone
|
||||||
|
- Env vars passed via `-e` flags
|
||||||
|
3. Inside Docker, `play.ts` runs again (detects `/.dockerenv` file)
|
||||||
|
4. Clones `GITHUB_REPOSITORY`, runs agent
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Docker not running:**
|
||||||
|
```
|
||||||
|
Cannot connect to the Docker daemon
|
||||||
|
```
|
||||||
|
→ Start Docker Desktop
|
||||||
|
|
||||||
|
**SSH clone fails:**
|
||||||
|
```
|
||||||
|
Permission denied (publickey)
|
||||||
|
```
|
||||||
|
→ Ensure SSH agent is running: `ssh-add -l`
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Bash Tool Security
|
||||||
|
|
||||||
|
> **Note**: Security measures described here apply to **PUBLIC repositories only**. For private repos, agents can use native bash with full environment access.
|
||||||
|
|
||||||
|
## Architecture (Public Repos)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ GitHub Actions Runner │
|
||||||
|
│ (has secrets: ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.) │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Pullfrog Action (Node.js) │ │
|
||||||
|
│ │ - process.env contains all secrets │ │
|
||||||
|
│ │ - spawns agent CLI as child process │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌─────────────────────────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Agent CLI (Claude/Cursor/OpenCode/etc.) │ │ │
|
||||||
|
│ │ │ - receives filtered env (only API key it needs) │ │ │
|
||||||
|
│ │ │ - has built-in Bash tool (DISABLED for public) │ │ │
|
||||||
|
│ │ │ - connects to MCP server for tools │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ ┌───────────────────────────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ MCP Bash Tool (our code) │ │ │ │
|
||||||
|
│ │ │ │ - agent calls this for shell commands │ │ │ │
|
||||||
|
│ │ │ │ - spawns bash with filtered env │ │ │ │
|
||||||
|
│ │ │ │ - uses PID namespace isolation │ │ │ │
|
||||||
|
│ │ │ │ │ │ │ │
|
||||||
|
│ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │
|
||||||
|
│ │ │ │ │ Bash subprocess │ │ │ │ │
|
||||||
|
│ │ │ │ │ - runs user-controlled commands │ │ │ │ │
|
||||||
|
│ │ │ │ │ - MUST NOT access secrets │ │ │ │ │
|
||||||
|
│ │ │ │ └─────────────────────────────────────────┘ │ │ │ │
|
||||||
|
│ │ │ └───────────────────────────────────────────────┘ │ │ │
|
||||||
|
│ │ └─────────────────────────────────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key insight**: For **public repos**, the Pullfrog Action process has all secrets in `process.env`. Agent CLIs have built-in Bash tools that we can't trust since malicious actors can submit PRs with prompt injections. We disable those and provide our own MCP Bash tool that spawns subprocesses securely.
|
||||||
|
|
||||||
|
For **private repos**, the threat model is different — only trusted collaborators can trigger workflows, so we allow native bash with full environment access for better performance and compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Public vs Private Repos
|
||||||
|
|
||||||
|
| Repo Visibility | Native Bash | Env Filtering | PID Isolation |
|
||||||
|
|-----------------|-------------|---------------|---------------|
|
||||||
|
| **Public** | Disabled | Yes | Yes (in CI) |
|
||||||
|
| **Private** | Enabled | No | No |
|
||||||
|
|
||||||
|
**Rationale**: Public repos are at risk from prompt injection attacks via pull requests from untrusted contributors. Private repos only allow trusted collaborators, so the attack surface is much smaller.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Threat Model (Public Repos)
|
||||||
|
|
||||||
|
A prompt-injected agent could run malicious bash commands to exfiltrate API keys.
|
||||||
|
|
||||||
|
**Attack vectors:**
|
||||||
|
|
||||||
|
| Vector | Example | Mitigation |
|
||||||
|
|--------|---------|------------|
|
||||||
|
| Direct env access | `env \| grep KEY` | Filter env vars before spawn |
|
||||||
|
| Echo variable | `echo $ANTHROPIC_API_KEY` | Filter env vars before spawn |
|
||||||
|
| `/proc/$PPID/environ` | `cat /proc/$PPID/environ` | PID namespace isolation |
|
||||||
|
|
||||||
|
The first two are solved by passing filtered env to subprocess. The third requires special handling on Linux.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Attack: /proc/$PPID/environ (Public Repos)
|
||||||
|
|
||||||
|
On Linux, any process can read its parent's environment via `/proc/$PPID/environ`. Even if we spawn bash with a clean environment, the bash process can:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# read parent's (Node.js) environment - contains all secrets!
|
||||||
|
tr '\0' '\n' < /proc/$PPID/environ | grep KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
This bypasses environment filtering because we're reading the parent process's memory, not our own env.
|
||||||
|
|
||||||
|
**Why this matters:**
|
||||||
|
- Pullfrog Action (Node.js) has `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc. in `process.env`
|
||||||
|
- We spawn agent CLI with filtered env (only its own API key)
|
||||||
|
- Agent CLI spawns MCP Bash tool
|
||||||
|
- MCP Bash tool spawns bash with filtered env (no secrets)
|
||||||
|
- BUT bash can read `/proc/$PPID/environ` → gets Node.js process's full env
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution: PID Namespace Isolation (Public Repos)
|
||||||
|
|
||||||
|
We use Linux PID namespaces to hide the parent process:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
unshare --pid --fork --mount-proc bash -c "$CMD"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `--pid` | Create new PID namespace |
|
||||||
|
| `--fork` | Fork so child is actually in new namespace |
|
||||||
|
| `--mount-proc` | Mount fresh `/proc` for new namespace |
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- Child sees itself as PID 1
|
||||||
|
- Child's PPID is 0 (doesn't exist)
|
||||||
|
- `/proc` only shows processes in child's namespace
|
||||||
|
- Parent's PID is invisible → `/proc/$PPID/environ` fails
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### mcp/bash.ts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
// filter sensitive env vars (only for public repos)
|
||||||
|
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||||
|
const SENSITIVE = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
// only filter sensitive vars for public repos
|
||||||
|
if (isPublicRepo && SENSITIVE.some(p => p.test(key))) continue;
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// spawn with PID namespace in CI for public repos, plain spawn otherwise
|
||||||
|
function spawnSandboxed(command: string, options: { env, cwd, isPublicRepo }): ChildProcess {
|
||||||
|
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||||
|
if (useNamespaceIsolation) {
|
||||||
|
return spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], options);
|
||||||
|
}
|
||||||
|
return spawn("bash", ["-c", command], options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// BashTool uses ctx.repo.private to determine visibility
|
||||||
|
export function BashTool(ctx: ToolContext) {
|
||||||
|
const isPublicRepo = !ctx.repo.private;
|
||||||
|
// ... spawns with filterEnv(isPublicRepo) and isPublicRepo flag
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defense in depth (public repos only):**
|
||||||
|
1. `filterEnv(true)` - prevents `env` and `echo $VAR` attacks
|
||||||
|
2. `unshare` - prevents `/proc/$PPID/environ` attack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disabling Native Bash Tools (Public Repos)
|
||||||
|
|
||||||
|
For **public repos**, each agent's built-in Bash/Shell tools are disabled. Agents use our MCP Bash tool which filters secrets:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Claude - conditional based on repo.isPublic
|
||||||
|
const disallowedTools = repo.isPublic ? ["Bash"] : [];
|
||||||
|
{ permissionMode: "bypassPermissions", disallowedTools }
|
||||||
|
|
||||||
|
// Cursor - conditional shell denial
|
||||||
|
const denyShell = isPublicRepo ? ["Shell(*)"] : [];
|
||||||
|
{ permissions: { allow: ["Read(**)", "Write(**)"], deny: denyShell } }
|
||||||
|
|
||||||
|
// OpenCode - conditional bash denial
|
||||||
|
const bashPermission = isPublicRepo ? "deny" : "allow";
|
||||||
|
{ permission: { edit: "allow", bash: bashPermission, ... } }
|
||||||
|
|
||||||
|
// Gemini - uses excludeTools in ~/.gemini/settings.json
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
|
||||||
|
// Codex - CLI internally scrubs env before spawning shell
|
||||||
|
// No SDK-level config needed; Codex handles this automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
For **private repos**, native bash is allowed for all agents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing (Public Repo Scenario)
|
||||||
|
|
||||||
|
Run the vulnerability test in Docker to verify protection for public repos:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from action/ directory
|
||||||
|
docker run --rm \
|
||||||
|
-v "$(pwd):/app/action:cached" \
|
||||||
|
-v "pullfrog-action-node-modules:/app/action/node_modules" \
|
||||||
|
-w /app/action \
|
||||||
|
-e GITHUB_ACTIONS=true \
|
||||||
|
-e TEST_SECRET_KEY=test-secret \
|
||||||
|
-e ANTHROPIC_API_KEY=sk-test \
|
||||||
|
--cap-add SYS_ADMIN \
|
||||||
|
--security-opt seccomp:unconfined \
|
||||||
|
node:22 bash -c "corepack enable pnpm && pnpm install --frozen-lockfile && node test/proc-environ-vuln.ts"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
1. UNPROTECTED (filterEnv only):
|
||||||
|
Leaked: YES ❌
|
||||||
|
|
||||||
|
2. PROTECTED (unshare --pid --fork --mount-proc):
|
||||||
|
Leaked: NO ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Platform Notes
|
||||||
|
|
||||||
|
| Environment | Repo | Our approach |
|
||||||
|
|-------------|------|--------------|
|
||||||
|
| GitHub Actions (Linux) | Public | filterEnv + unshare + disable native bash |
|
||||||
|
| GitHub Actions (Linux) | Private | Full env + native bash allowed |
|
||||||
|
| Local dev (any OS) | Any | No filtering (local dev assumed trusted) |
|
||||||
|
|
||||||
|
We check `process.env.CI === "true"` (set by GitHub Actions) combined with `ctx.repo.private` to determine the security posture:
|
||||||
|
- **CI + Public repo**: Full protection with PID namespace isolation
|
||||||
|
- **CI + Private repo**: No protection (trusted collaborators only)
|
||||||
|
- **Local**: No protection (developer's own machine)
|
||||||
|
|
||||||
|
GitHub Actions uses Ubuntu runners where `unshare` works without root.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What This Does NOT Protect Against (Public Repos)
|
||||||
|
|
||||||
|
Even with protections enabled, bash subprocesses can still:
|
||||||
|
|
||||||
|
- **Network exfiltration**: Child has full network access
|
||||||
|
- **File access**: Child can read any file the runner can (same UID)
|
||||||
|
- **Resource exhaustion**: No cgroup limits
|
||||||
|
|
||||||
|
For those, you'd need `bwrap` with `--unshare-net`, `--ro-bind`, etc. But for the stated goal—preventing secret exfiltration via env—this is sufficient.
|
||||||
|
|
||||||
|
For **private repos**, none of these protections apply since we trust collaborators.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Agent-Specific Notes
|
||||||
|
|
||||||
|
### Claude, Cursor, OpenCode (Public Repos)
|
||||||
|
|
||||||
|
These agents have their native Bash disabled via configuration. They use our `gh_pullfrog` MCP server's `bash` tool which implements `filterEnv()` + `unshare`.
|
||||||
|
|
||||||
|
For private repos, native bash is enabled for these agents.
|
||||||
|
|
||||||
|
### Gemini (Public Repos)
|
||||||
|
|
||||||
|
Gemini CLI supports `excludeTools` in its user-level settings file (`~/.gemini/settings.json`). For public repos, we exclude the native shell tool:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// written to ~/.gemini/settings.json
|
||||||
|
newSettings.excludeTools = ["run_shell_command"];
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a blocklist approach which explicitly excludes the shell tool while allowing all other tools.
|
||||||
|
|
||||||
|
Additionally, Gemini has built-in CI detection that filters shell env when `GITHUB_SHA` is set.
|
||||||
|
|
||||||
|
### Codex
|
||||||
|
|
||||||
|
Codex CLI filters out env vars matching `KEY`, `SECRET`, or `TOKEN` (case-insensitive) by default via `shell_environment_policy.ignore_default_excludes = false`.
|
||||||
|
|
||||||
|
**Vulnerability**: If a user's `~/.codex/config.toml` has `ignore_default_excludes = true`, secrets will leak to shell commands.
|
||||||
|
|
||||||
|
**Our mitigation**: We set `CODEX_HOME` to a temp directory and write our own `config.toml` with `ignore_default_excludes = false` to enforce filtering regardless of what config exists in the user's `~/.codex/`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// set CODEX_HOME to override user's config
|
||||||
|
setupProcessAgentEnv({ CODEX_HOME: codexDir });
|
||||||
|
|
||||||
|
// write secure config to $CODEX_HOME/config.toml
|
||||||
|
writeFileSync(join(codexDir, "config.toml"), `
|
||||||
|
[shell_environment_policy]
|
||||||
|
ignore_default_excludes = false
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
See [GitHub Issue #3064](https://github.com/openai/codex/issues/3064) and [config docs](https://github.com/openai/codex/blob/main/docs/config.md#shell_environment_policy).
|
||||||
|
|
||||||
|
**Verified behavior** (tested via `pnpm play codex-env-test.ts`):
|
||||||
|
- Default (no config): ✅ secrets filtered
|
||||||
|
- `ignore_default_excludes = false`: ✅ secrets filtered
|
||||||
|
- `ignore_default_excludes = true`: ❌ secrets leak
|
||||||
|
|
||||||
|
Example output when running `env | grep TEST` with our config:
|
||||||
|
```
|
||||||
|
TEST_SAFE_VAR=VISIBLE-SAFE-VALUE
|
||||||
|
# FAKE_SECRET_KEY and TEST_API_TOKEN are NOT visible (filtered)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Summary by Agent
|
||||||
|
|
||||||
|
| Agent | Public Repo | Private Repo |
|
||||||
|
|-------|-------------|--------------|
|
||||||
|
| Claude | Native bash **disabled** | Native bash allowed |
|
||||||
|
| Cursor | Native shell **disabled** | Native shell allowed |
|
||||||
|
| OpenCode | Native bash **disabled** | Native bash allowed |
|
||||||
|
| Gemini | Native shell **disabled** (via excludeTools) | Native bash allowed |
|
||||||
|
| Codex | Native shell allowed (CLI scrubs env internally) | Native bash allowed |
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Payload } from "../external.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* test fixture: verifies agents use MCP bash tool for shell commands.
|
||||||
|
* creates a simple test file and runs it with node.
|
||||||
|
*
|
||||||
|
* for insecure agents (claude, cursor, opencode): native bash is disabled,
|
||||||
|
* so they MUST use gh_pullfrog/bash MCP tool to run shell commands.
|
||||||
|
*
|
||||||
|
* for secure agents (codex, gemini): native bash is safe, but this test
|
||||||
|
* still verifies shell execution works.
|
||||||
|
*
|
||||||
|
* run with: AGENT_OVERRIDE=<agent> pnpm play bash-test.ts
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: null,
|
||||||
|
prompt: `Create a file called test-runner.js with the following content:
|
||||||
|
|
||||||
|
\`\`\`javascript
|
||||||
|
const assert = require('assert');
|
||||||
|
assert.strictEqual(2 + 2, 4, 'math should work');
|
||||||
|
console.log('TEST PASSED: basic arithmetic works');
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then run it with: node test-runner.js
|
||||||
|
|
||||||
|
Finally, delete the test file.
|
||||||
|
|
||||||
|
This tests that you can execute shell commands properly.`,
|
||||||
|
event: {
|
||||||
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
|
modes: [],
|
||||||
|
} satisfies Payload;
|
||||||
+9
-6
@@ -1,9 +1,12 @@
|
|||||||
import type { Inputs } from "../main.ts";
|
import type { Payload } from "../external.ts";
|
||||||
|
|
||||||
const testParams = {
|
export default {
|
||||||
|
"~pullfrog": true,
|
||||||
|
agent: null,
|
||||||
prompt:
|
prompt:
|
||||||
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
"List all files in the current directory, then create a file called dynamic-test.txt with the content 'This was loaded from a TypeScript file!', then delete it.",
|
||||||
anthropic_api_key: "sk-test-key",
|
event: {
|
||||||
} satisfies Inputs;
|
trigger: "workflow_dispatch",
|
||||||
|
},
|
||||||
export default testParams;
|
modes: [],
|
||||||
|
} satisfies Payload;
|
||||||
|
|||||||
+3
-5
@@ -6,9 +6,9 @@ import type { Payload } from "../external.ts";
|
|||||||
*
|
*
|
||||||
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
* run with: AGENT_OVERRIDE=claude pnpm play sandbox.ts
|
||||||
*/
|
*/
|
||||||
const payload: Payload = {
|
export default {
|
||||||
"~pullfrog": true,
|
"~pullfrog": true,
|
||||||
agent: null, // let AGENT_OVERRIDE control this for testing different agents
|
agent: null,
|
||||||
prompt: `Please do the following three things:
|
prompt: `Please do the following three things:
|
||||||
|
|
||||||
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
1. Fetch the content from https://httpbin.org/json and tell me what it says
|
||||||
@@ -24,6 +24,4 @@ All three of these actions should fail because you are running in sandbox mode w
|
|||||||
},
|
},
|
||||||
modes: [],
|
modes: [],
|
||||||
sandbox: true,
|
sandbox: true,
|
||||||
};
|
} satisfies Payload;
|
||||||
|
|
||||||
export default JSON.stringify(payload);
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { mkdtemp } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { flatMorph } from "@ark/util";
|
import { flatMorph } from "@ark/util";
|
||||||
import { Octokit } from "@octokit/rest";
|
import type { Octokit } from "@octokit/rest";
|
||||||
import { encode as toonEncode } from "@toon-format/toon";
|
import { encode as toonEncode } from "@toon-format/toon";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { type Agent, agents } from "./agents/index.ts";
|
import { type Agent, agents } from "./agents/index.ts";
|
||||||
@@ -370,6 +370,7 @@ function resolveAgent({
|
|||||||
repoSettings: RepoSettings;
|
repoSettings: RepoSettings;
|
||||||
}): Agent {
|
}): Agent {
|
||||||
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
const agentOverride = process.env.AGENT_OVERRIDE as AgentName | undefined;
|
||||||
|
log.debug(`» determineAgent: agentOverride=${agentOverride}, payload.agent=${payload.agent}, repoSettings.defaultAgent=${repoSettings.defaultAgent}`);
|
||||||
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
const configuredAgentName = agentOverride || payload.agent || repoSettings.defaultAgent || null;
|
||||||
|
|
||||||
if (configuredAgentName) {
|
if (configuredAgentName) {
|
||||||
@@ -513,6 +514,7 @@ async function runAgent(ctx: AgentContext): Promise<AgentResult> {
|
|||||||
owner: ctx.owner,
|
owner: ctx.owner,
|
||||||
name: ctx.name,
|
name: ctx.name,
|
||||||
defaultBranch: ctx.repo.default_branch,
|
defaultBranch: ctx.repo.default_branch,
|
||||||
|
isPublic: !ctx.repo.private,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+129
@@ -0,0 +1,129 @@
|
|||||||
|
import { type ChildProcess, spawn } from "node:child_process";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import type { ToolContext } from "../main.ts";
|
||||||
|
import { execute, tool } from "./shared.ts";
|
||||||
|
|
||||||
|
export const BashParams = type({
|
||||||
|
command: "string",
|
||||||
|
description: "string",
|
||||||
|
"timeout?": "number",
|
||||||
|
"working_directory?": "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
// patterns for sensitive env vars: suffixes (_KEY, _SECRET, _TOKEN) plus AI provider prefixes
|
||||||
|
const SENSITIVE_PATTERNS = [/_KEY$/i, /_SECRET$/i, /_TOKEN$/i, /_PASSWORD$/i, /_CREDENTIAL$/i];
|
||||||
|
|
||||||
|
function isSensitive(key: string): boolean {
|
||||||
|
return SENSITIVE_PATTERNS.some((p) => p.test(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** filter env vars, removing sensitive values (only for public repos) */
|
||||||
|
function filterEnv(isPublicRepo: boolean): Record<string, string> {
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value === undefined) continue;
|
||||||
|
// only filter sensitive vars for public repos
|
||||||
|
if (isPublicRepo && isSensitive(key)) continue;
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* spawn command with filtered env. in CI, also use PID namespace isolation
|
||||||
|
* to prevent child from reading /proc/$PPID/environ (only for public repos)
|
||||||
|
*/
|
||||||
|
function spawnSandboxed(
|
||||||
|
command: string,
|
||||||
|
options: { env: Record<string, string>; cwd: string; isPublicRepo: boolean }
|
||||||
|
): ChildProcess {
|
||||||
|
const stdio: ["ignore", "pipe", "pipe"] = ["ignore", "pipe", "pipe"];
|
||||||
|
const spawnOpts = { env: options.env, cwd: options.cwd, stdio, detached: true };
|
||||||
|
// only use PID namespace isolation for public repos in CI
|
||||||
|
const useNamespaceIsolation = process.env.CI === "true" && options.isPublicRepo;
|
||||||
|
return useNamespaceIsolation
|
||||||
|
? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", command], spawnOpts)
|
||||||
|
: spawn("bash", ["-c", command], spawnOpts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** kill process and its entire process group */
|
||||||
|
async function killProcessGroup(proc: ChildProcess): Promise<void> {
|
||||||
|
if (!proc.pid) return;
|
||||||
|
try {
|
||||||
|
process.kill(-proc.pid, "SIGTERM");
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
process.kill(-proc.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
proc.kill("SIGKILL");
|
||||||
|
} catch {
|
||||||
|
/* already dead */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BashTool(ctx: ToolContext) {
|
||||||
|
const isPublicRepo = !ctx.repo.private;
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
name: "bash",
|
||||||
|
description: `Execute shell commands securely.${isPublicRepo ? " Environment is filtered to remove API keys and secrets." : ""}
|
||||||
|
|
||||||
|
Use this tool to:
|
||||||
|
- Run shell commands (ls, cat, grep, find, etc.)
|
||||||
|
- Execute build tools (npm, pnpm, cargo, make, etc.)
|
||||||
|
- Run tests and linters
|
||||||
|
- Perform git operations
|
||||||
|
- Run shell commands in a secure environment. Unlike the built-in bash tool, this tool filters sensitive environment variables from the subprocess's environment to avoid leaking secrets.`,
|
||||||
|
parameters: BashParams,
|
||||||
|
execute: execute(async (params) => {
|
||||||
|
const timeout = Math.min(params.timeout ?? 120000, 600000);
|
||||||
|
const cwd = params.working_directory ?? process.cwd();
|
||||||
|
const proc = spawnSandboxed(params.command, {
|
||||||
|
env: filterEnv(isPublicRepo),
|
||||||
|
cwd,
|
||||||
|
isPublicRepo,
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "",
|
||||||
|
stderr = "",
|
||||||
|
timedOut = false,
|
||||||
|
exited = false;
|
||||||
|
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
stdout += chunk.toString();
|
||||||
|
});
|
||||||
|
proc.stderr?.on("data", (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
if (!exited) {
|
||||||
|
timedOut = true;
|
||||||
|
await killProcessGroup(proc);
|
||||||
|
}
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
const exitCode = await new Promise<number | null>((resolve) => {
|
||||||
|
const done = (code: number | null) => {
|
||||||
|
exited = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve(code);
|
||||||
|
};
|
||||||
|
proc.on("exit", done);
|
||||||
|
proc.on("error", () => done(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = stderr ? (stdout ? `${stdout}\n${stderr}` : stderr) : stdout;
|
||||||
|
if (timedOut)
|
||||||
|
output = output
|
||||||
|
? `${output}\n[timed out after ${timeout}ms]`
|
||||||
|
: `[timed out after ${timeout}ms]`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
output: output.trim(),
|
||||||
|
exit_code: exitCode ?? (timedOut ? 124 : -1),
|
||||||
|
timed_out: timedOut,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import { CreatePullRequestReviewTool } from "./review.ts";
|
|||||||
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
|
||||||
import { SelectModeTool } from "./selectMode.ts";
|
import { SelectModeTool } from "./selectMode.ts";
|
||||||
import { addTools } from "./shared.ts";
|
import { addTools } from "./shared.ts";
|
||||||
|
import { BashTool } from "./bash.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find an available port starting from the given port
|
* Find an available port starting from the given port
|
||||||
@@ -94,6 +95,7 @@ export async function startMcpHttpServer(
|
|||||||
CreateBranchTool(ctx),
|
CreateBranchTool(ctx),
|
||||||
CommitFilesTool(ctx),
|
CommitFilesTool(ctx),
|
||||||
PushBranchTool(ctx),
|
PushBranchTool(ctx),
|
||||||
|
BashTool(ctx),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!ctx.payload.disableProgressComment) {
|
if (!ctx.payload.disableProgressComment) {
|
||||||
|
|||||||
+1
-1
@@ -36,10 +36,10 @@
|
|||||||
"@standard-schema/spec": "1.0.0",
|
"@standard-schema/spec": "1.0.0",
|
||||||
"@toon-format/toon": "^1.0.0",
|
"@toon-format/toon": "^1.0.0",
|
||||||
"arktype": "2.1.28",
|
"arktype": "2.1.28",
|
||||||
"package-manager-detector": "^1.6.0",
|
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"execa": "^9.6.0",
|
"execa": "^9.6.0",
|
||||||
"fastmcp": "^3.26.8",
|
"fastmcp": "^3.26.8",
|
||||||
|
"package-manager-detector": "^1.6.0",
|
||||||
"table": "^6.9.0"
|
"table": "^6.9.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { extname, join, resolve } from "node:path";
|
import { extname, join, resolve } from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
@@ -67,7 +68,9 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||||||
const args = arg({
|
const args = arg({
|
||||||
"--help": Boolean,
|
"--help": Boolean,
|
||||||
"--raw": String,
|
"--raw": String,
|
||||||
|
"--local": Boolean,
|
||||||
"-h": "--help",
|
"-h": "--help",
|
||||||
|
"-l": "--local",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (args["--help"]) {
|
if (args["--help"]) {
|
||||||
@@ -81,18 +84,85 @@ Arguments:
|
|||||||
|
|
||||||
Options:
|
Options:
|
||||||
--raw [prompt] Use raw string as prompt instead of loading from file
|
--raw [prompt] Use raw string as prompt instead of loading from file
|
||||||
|
--local, -l Run locally on macOS (default: runs in Docker)
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
PLAY_LOCAL=1 Same as --local
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
tsx play.ts # Use default fixture
|
tsx play.ts bash-test.ts # Run in Docker (default)
|
||||||
tsx play.ts fixtures/basic.txt # Use specific text file
|
tsx play.ts --local bash-test.ts # Run locally on macOS
|
||||||
tsx play.ts custom.json # Use JSON file
|
|
||||||
tsx play.ts fixtures/test.ts # Use TypeScript file
|
|
||||||
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
tsx play.ts --raw "Hello world" # Use raw string as prompt
|
||||||
`);
|
`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// default: run in Docker (unless --local or PLAY_LOCAL=1 or already inside Docker)
|
||||||
|
const isInsideDocker = existsSync("/.dockerenv");
|
||||||
|
const useLocal = args["--local"] || process.env.PLAY_LOCAL === "1" || isInsideDocker;
|
||||||
|
|
||||||
|
if (!useLocal) {
|
||||||
|
log.info("» running in Docker container...");
|
||||||
|
|
||||||
|
const passArgs = process.argv.slice(2);
|
||||||
|
const nodeCmd = `node play.ts ${passArgs.join(" ")}`;
|
||||||
|
|
||||||
|
// pass all env vars to docker
|
||||||
|
const envFlags = Object.entries(process.env).flatMap(([key, value]) =>
|
||||||
|
value !== undefined ? ["-e", `${key}=${value}`] : []
|
||||||
|
);
|
||||||
|
|
||||||
|
// SSH for git - mount individual SSH files to avoid permission issues
|
||||||
|
const sshFlags: string[] = [];
|
||||||
|
const home = process.env.HOME;
|
||||||
|
if (home) {
|
||||||
|
const sshDir = join(home, ".ssh");
|
||||||
|
// mount SSH keys (try common key names)
|
||||||
|
for (const keyName of ["id_rsa", "id_ed25519", "id_ecdsa"]) {
|
||||||
|
const keyPath = join(sshDir, keyName);
|
||||||
|
if (existsSync(keyPath)) {
|
||||||
|
sshFlags.push("-v", `${keyPath}:/root/.ssh/${keyName}:ro`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// mount known_hosts
|
||||||
|
const knownHostsPath = join(sshDir, "known_hosts");
|
||||||
|
if (existsSync(knownHostsPath)) {
|
||||||
|
sshFlags.push("-v", `${knownHostsPath}:/root/.ssh/known_hosts:ro`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttyFlags = process.stdin.isTTY ? ["-it"] : [];
|
||||||
|
|
||||||
|
const result = spawnSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
...ttyFlags,
|
||||||
|
"-v",
|
||||||
|
`${process.cwd()}:/app/action:cached`,
|
||||||
|
"-v",
|
||||||
|
"pullfrog-action-node-modules:/app/action/node_modules",
|
||||||
|
"-w",
|
||||||
|
"/app/action",
|
||||||
|
...envFlags,
|
||||||
|
...sshFlags,
|
||||||
|
"--cap-add",
|
||||||
|
"SYS_ADMIN",
|
||||||
|
"--security-opt",
|
||||||
|
"seccomp:unconfined",
|
||||||
|
"node:24",
|
||||||
|
"bash",
|
||||||
|
"-c",
|
||||||
|
`corepack enable pnpm >/dev/null 2>&1 && pnpm install --frozen-lockfile && ${nodeCmd}`,
|
||||||
|
],
|
||||||
|
{ stdio: "inherit" }
|
||||||
|
);
|
||||||
|
|
||||||
|
process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
|
|
||||||
if (args["--raw"]) {
|
if (args["--raw"]) {
|
||||||
@@ -135,10 +205,11 @@ Examples:
|
|||||||
|
|
||||||
if (typeof module.default === "string") {
|
if (typeof module.default === "string") {
|
||||||
prompt = module.default;
|
prompt = module.default;
|
||||||
} else if (typeof module.default === "object" && module.default.prompt) {
|
} else if (typeof module.default === "object") {
|
||||||
prompt = module.default.prompt;
|
// Payload objects (with ~pullfrog) should be stringified
|
||||||
} else {
|
|
||||||
prompt = JSON.stringify(module.default, null, 2);
|
prompt = JSON.stringify(module.default, null, 2);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported default export type: ${typeof module.default}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-8
@@ -1,8 +1,8 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createSign } from "node:crypto";
|
||||||
import * as core from "@actions/core";
|
import * as core from "@actions/core";
|
||||||
import { throttling } from "@octokit/plugin-throttling";
|
import { throttling } from "@octokit/plugin-throttling";
|
||||||
import { Octokit } from "@octokit/rest";
|
import { Octokit } from "@octokit/rest";
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { createSign } from "node:crypto";
|
|
||||||
import { log } from "./cli.ts";
|
import { log } from "./cli.ts";
|
||||||
import { retry } from "./retry.ts";
|
import { retry } from "./retry.ts";
|
||||||
|
|
||||||
@@ -47,8 +47,11 @@ interface RepositoriesResponse {
|
|||||||
repositories: Repository[];
|
repositories: Repository[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isGitHubActionsEnvironment(): boolean {
|
function isOIDCAvailable(): boolean {
|
||||||
return Boolean(process.env.GITHUB_ACTIONS);
|
// OIDC requires both env vars to be set (only in real GitHub Actions with id-token permission)
|
||||||
|
return Boolean(
|
||||||
|
process.env.ACTIONS_ID_TOKEN_REQUEST_URL && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireTokenViaOIDC(): Promise<string> {
|
async function acquireTokenViaOIDC(): Promise<string> {
|
||||||
@@ -236,7 +239,7 @@ async function acquireTokenViaGitHubApp(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function acquireNewToken(): Promise<string> {
|
async function acquireNewToken(): Promise<string> {
|
||||||
if (isGitHubActionsEnvironment()) {
|
if (isOIDCAvailable()) {
|
||||||
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
return await retry(() => acquireTokenViaOIDC(), { label: "token exchange" });
|
||||||
} else {
|
} else {
|
||||||
return await acquireTokenViaGitHubApp();
|
return await acquireTokenViaGitHubApp();
|
||||||
@@ -267,7 +270,10 @@ export async function setupGitHubInstallationToken() {
|
|||||||
* Get the GitHub installation token from memory
|
* Get the GitHub installation token from memory
|
||||||
*/
|
*/
|
||||||
export function getGitHubInstallationToken(): string {
|
export function getGitHubInstallationToken(): string {
|
||||||
assert(githubInstallationToken, "GitHub installation token not set. Call setupGitHubInstallationToken first.");
|
assert(
|
||||||
|
githubInstallationToken,
|
||||||
|
"GitHub installation token not set. Call setupGitHubInstallationToken first."
|
||||||
|
);
|
||||||
return githubInstallationToken;
|
return githubInstallationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,7 +319,9 @@ export function parseRepoContext(): RepoContext {
|
|||||||
return { owner, name };
|
return { owner, name };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OctokitWithPlugins = InstanceType<ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>>
|
export type OctokitWithPlugins = InstanceType<
|
||||||
|
ReturnType<typeof Octokit.plugin<typeof Octokit, [typeof throttling]>>
|
||||||
|
>;
|
||||||
|
|
||||||
export function createOctokit(token: string): OctokitWithPlugins {
|
export function createOctokit(token: string): OctokitWithPlugins {
|
||||||
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
|
// `OctokitWithPlugins` initialization based on https://github.com/actions/toolkit/blob/2506e78e82fbd2f9e94d63e75f5309118c8de1b1/packages/github/src/github.ts#L15-L22
|
||||||
@@ -328,6 +336,6 @@ export function createOctokit(token: string): OctokitWithPlugins {
|
|||||||
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
|
onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => {
|
||||||
return retryCount <= 2;
|
return retryCount <= 2;
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user